diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 6aa86cbb6..01a4a2523 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -28,7 +28,7 @@ "hideArchived": "Hide Archived", "showArchived": "Show Archived", "notAccessTitle": "You don't have permission to access", - "hideColumns": "Hide Columns", + "hideColumns": "Columns", "clearFilters": "Clear Filters", "selectAll": "Select All", "selectNone": "Select None" @@ -362,7 +362,9 @@ "fields_help": { "empty_spool_weight": "The weight of an empty spool from this manufacturer.", "logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png.", - "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png." + "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png.", + "logo_suggestions": "Checks the logo database for files with names similar to this manufacturer.", + "print_logo_suggestions": "Checks the print-logo database for files with names similar to this manufacturer." }, "titles": { "create": "Create Manufacturer", @@ -374,14 +376,19 @@ }, "buttons": { "sync_logos": "Sync Logos", - "clear_logo_url": "Clear URL" + "clear_logo_url": "Clear URL", + "convert_logo_to_print": "Convert Logo to Print", + "convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file." }, "form": { "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", "logo_sync_no_match": "No matching logos found for this manufacturer name.", "logo_sync_applied": "Suggested logo paths applied.", "logo_preview_auto_notice": "using auto-matched logo from bundled logo pack", - "logo_preview_default_notice": "no logo defined, using default generated text logo" + "logo_preview_default_notice": "no logo defined, using default generated text logo", + "logo_convert_requires_web_logo": "Set a Logo URL first.", + "logo_convert_success": "Generated print logo from web logo.", + "logo_convert_error": "Could not generate a print logo from this Logo URL." } }, "home": { diff --git a/client/src/components/colorHexPreview.tsx b/client/src/components/colorHexPreview.tsx index b5e4d57a9..1931795cb 100644 --- a/client/src/components/colorHexPreview.tsx +++ b/client/src/components/colorHexPreview.tsx @@ -38,8 +38,8 @@ export default function ColorHexPreview({ colorHex, multiColorHexes, multiColorD if (isLongitudinal) { return (
- {colors.map((hex) => ( -
+ {colors.map((hex, index) => ( +
+ return ( +
{colors.map((hex, index) => ( -
+
+ + {hex} +
))}
); - - if (colors.length === 2) { - return ( -
- {colors[0]} - {strip} - {colors[1]} -
- ); - } - - const middle = colors.slice(1, -1); - return ( -
-
- {colors[0]} - {strip} - {colors[colors.length - 1]} -
-
- {middle.map((hex, index) => ( - - {hex} - - ))} -
-
- ); } diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 78425ef62..e017cd5f4 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -46,6 +46,41 @@ function valueKey(value: Key): string { return String(value); } +function normalizeSearchableValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)).join(", "); + } + return String(value); +} + +function getRecordValue(record: unknown, dataIndex: string | string[]): unknown { + if (Array.isArray(dataIndex)) { + return dataIndex.reduce((current, part) => { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + return (current as Record)[part]; + }, record); + } + + if (record !== null && record !== undefined && typeof record === "object") { + const recordObject = record as Record; + if (Object.prototype.hasOwnProperty.call(recordObject, dataIndex)) { + return recordObject[dataIndex]; + } + } + + return dataIndex.split(".").reduce((current, part) => { + if (current === null || current === undefined || typeof current !== "object") { + return undefined; + } + return (current as Record)[part]; + }, record); +} + function FilterDropdownContent(props: { items: ColumnFilterItem[]; selectedKeys: Key[]; @@ -207,6 +242,56 @@ function FilterDropdownContent(props: { ); } + +function SearchFilterDropdownContent(props: { + selectedKeys: Key[]; + setSelectedKeys: (keys: Key[]) => void; + confirm: () => void; + clearFilters?: () => void; + t: (key: string) => string; + placeholder: string; +}) { + const { selectedKeys, setSelectedKeys, confirm, clearFilters, t, placeholder } = props; + const currentValue = selectedKeys.length > 0 ? String(selectedKeys[0]) : ""; + + return ( +
+ { + const value = event.target.value; + setSelectedKeys(value ? [value] : []); + }} + onPressEnter={() => confirm()} + /> + + + + +
+ ); +} + interface Entity { id: number; } @@ -226,6 +311,9 @@ interface BaseColumnProps { title?: string; align?: AlignType; sorter?: boolean; + searchable?: boolean; + searchPlaceholder?: string; + searchValueFormatter?: (rawValue: unknown, record: Obj) => string; t: (key: string) => string; navigate: (link: string) => void; dataSource: Obj[]; @@ -314,6 +402,69 @@ function Column( if (props.dataId) { columnProps.key = props.dataId; } + } else if (props.searchable) { + const filterField = props.dataId ?? (Array.isArray(props.id) ? undefined : (props.id as keyof Obj)); + if (filterField) { + const typedFilters = typeFilters(props.tableState.filters); + const filteredValue = getFiltersForField(typedFilters, filterField); + const searchableValues = new Map(); + const searchValueDataIndex = props.dataId ?? props.id; + + props.dataSource.forEach((record) => { + const rawValue = getRecordValue(record, searchValueDataIndex); + const displayValue = props.searchValueFormatter + ? props.searchValueFormatter(rawValue, record) + : normalizeSearchableValue(rawValue); + const normalizedDisplayValue = displayValue ?? ""; + const filterValue = normalizedDisplayValue === "" ? "" : normalizedDisplayValue; + if (!searchableValues.has(filterValue)) { + searchableValues.set(filterValue, normalizedDisplayValue); + } + }); + + const searchableFilters: ColumnFilterItem[] = Array.from(searchableValues.entries()) + .map(([value, label]) => ({ value, text: label })) + .sort((left, right) => + filterSearchTerm(left).localeCompare(filterSearchTerm(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + columnProps.filteredValue = filteredValue; + + if (searchableFilters.length > 0) { + columnProps.filters = searchableFilters; + columnProps.filterMultiple = true; + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( + + ); + } else { + columnProps.filterMultiple = false; + columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => ( + + ); + } + + if (props.dataId) { + columnProps.key = props.dataId; + } + } } // Render @@ -363,6 +514,7 @@ export function SortedColumn(props: BaseColumnProps) { return Column({ ...props, sorter: true, + searchable: props.searchable ?? true, }); } @@ -371,6 +523,7 @@ export function RichColumn( ) { return Column({ ...props, + searchable: props.searchable ?? true, render: (rawValue: string | undefined) => { const value = props.transform ? props.transform(rawValue) : rawValue; return enrichText(value); @@ -382,6 +535,7 @@ interface FilteredQueryColumnProps extends BaseColumnProps; allowMultipleFilters?: boolean; includeEmptyFilter?: boolean; + emptyFilterLabel?: string; } export function FilteredQueryColumn(props: FilteredQueryColumnProps) { @@ -401,7 +555,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu } if (props.includeEmptyFilter !== false) { filters.push({ - text: "", + text: props.emptyFilterLabel ?? "", value: "", }); } @@ -435,6 +589,7 @@ export function NumberColumn(props: NumberColumnProps) return Column({ ...props, align: "right", + searchable: props.searchable ?? true, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; if (value === null || value === undefined) { @@ -457,6 +612,14 @@ export function NumberColumn(props: NumberColumnProps) export function DateColumn(props: BaseColumnProps) { return Column({ ...props, + searchable: props.searchable ?? true, + searchValueFormatter: (rawValue) => { + const value = props.transform ? props.transform(rawValue) : rawValue; + if (!value) { + return ""; + } + return dayjs.utc(value as string).local().format("YYYY-MM-DD HH:mm"); + }, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; return ( @@ -477,7 +640,10 @@ export function ActionsColumn( ): ColumnType | undefined { return { title, + key: "actions", responsive: ["lg"], + fixed: "right", + width: 190, render: (_, record) => { const buttons = actionsFn(record).map((action) => { if (action.link) { @@ -570,6 +736,7 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< export function NumberRangeColumn(props: NumberColumnProps) { return Column({ ...props, + searchable: props.searchable ?? true, render: (rawValue) => { const value = props.transform ? props.transform(rawValue) : rawValue; if (value === null || value === undefined) { diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 1eb6c9b75..f2cbfe707 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -86,29 +86,18 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) { } export function useSpoolmanFilamentNames(enabled: boolean = false) { - return useQuery({ - enabled: enabled, - queryKey: ["filaments"], + return useQuery({ + enabled, + queryKey: ["filamentNames"], queryFn: async () => { - const response = await fetch(getAPIURL() + "/filament"); + const response = await fetch(getAPIURL() + "/filament/name"); if (!response.ok) { throw new Error("Network response was not ok"); } return response.json(); }, select: (data) => { - // Concatenate vendor name and filament name - let names = data - .filter((filament) => { - return filament.name !== null && filament.name !== undefined && filament.name !== ""; - }) - .map((filament) => { - return filament.name ?? ""; - }) - .sort(); - // Remove duplicates - names = [...new Set(names)]; - return names; + return data.filter((name) => name !== "").sort(); }, }); } diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx index e683a7812..556f5a18e 100644 --- a/client/src/components/qrCodeScanner.tsx +++ b/client/src/components/qrCodeScanner.tsx @@ -45,7 +45,13 @@ const QRCodeScannerModal = () => { return ( <> - setVisible(true)} icon={} shape="circle" /> + setVisible(true)} + icon={} + shape="circle" + style={{ right: "var(--camera-button-right)", bottom: "var(--camera-button-bottom)" }} + /> setVisible(false)} footer={null} title={t("scanner.title")}>

{t("scanner.description")}

diff --git a/client/src/components/resizableTable.tsx b/client/src/components/resizableTable.tsx new file mode 100644 index 000000000..da9e759ad --- /dev/null +++ b/client/src/components/resizableTable.tsx @@ -0,0 +1,309 @@ +import { Table } from "antd"; +import type { TableProps } from "antd"; +import type { AnyObject } from "antd/es/_util/type"; +import type { ColumnType, ColumnsType } from "antd/es/table"; +import { useMemo } from "react"; +import type { HTMLAttributes, MouseEvent as ReactMouseEvent, ThHTMLAttributes } from "react"; +import { useSavedState } from "../utils/saveload"; + +interface ResizableHeaderCellProps extends ThHTMLAttributes { + onResizeStart?: (event: ReactMouseEvent) => void; + onResizeAutoFit?: (event: ReactMouseEvent) => void; + resizable?: boolean; +} + +function ResizableHeaderCell({ className, onResizeStart, onResizeAutoFit, resizable, children, ...restProps }: ResizableHeaderCellProps) { + return ( + + {children} + {resizable && ( + { + event.preventDefault(); + event.stopPropagation(); + onResizeStart?.(event); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onResizeAutoFit?.(event); + }} + /> + )} + + ); +} + +function serializeDataIndex(dataIndex: unknown): string | undefined { + if (Array.isArray(dataIndex)) { + return dataIndex.map((part) => String(part)).join("."); + } + if (typeof dataIndex === "string" || typeof dataIndex === "number") { + return String(dataIndex); + } + return undefined; +} + +function columnIdentifier(column: ColumnType, index: number, parentId: string): string { + const key = column.key != null ? String(column.key) : undefined; + const dataIndex = serializeDataIndex(column.dataIndex); + return key ?? dataIndex ?? `${parentId}-${index}`; +} + +function measureIntrinsicElementWidth(element: HTMLElement): number { + const clone = element.cloneNode(true) as HTMLElement; + clone.querySelectorAll(".resizable-table-header-handle").forEach((handle) => handle.remove()); + clone.style.position = "fixed"; + clone.style.left = "-99999px"; + clone.style.top = "-99999px"; + clone.style.width = "max-content"; + clone.style.maxWidth = "none"; + clone.style.minWidth = "0"; + clone.style.display = "inline-block"; + clone.style.whiteSpace = "nowrap"; + clone.style.visibility = "hidden"; + document.body.appendChild(clone); + const measuredWidth = clone.getBoundingClientRect().width; + clone.remove(); + return measuredWidth; +} + +function measureCellAutoFitWidth(cell: HTMLTableCellElement): number { + const style = window.getComputedStyle(cell); + const paddingLeft = Number.parseFloat(style.paddingLeft || "0") || 0; + const paddingRight = Number.parseFloat(style.paddingRight || "0") || 0; + const horizontalPadding = paddingLeft + paddingRight; + + const childElements = Array.from(cell.children).filter((child): child is HTMLElement => child instanceof HTMLElement); + if (childElements.length > 0) { + const widestChild = childElements.reduce((maxWidth, child) => { + return Math.max(maxWidth, measureIntrinsicElementWidth(child)); + }, 0); + return Math.ceil(widestChild + horizontalPadding); + } + + const text = cell.textContent?.trim() ?? ""; + if (text.length === 0) { + return Math.ceil(horizontalPadding); + } + + const textProbe = document.createElement("span"); + textProbe.textContent = text; + textProbe.style.position = "fixed"; + textProbe.style.left = "-99999px"; + textProbe.style.top = "-99999px"; + textProbe.style.whiteSpace = "nowrap"; + textProbe.style.font = style.font; + textProbe.style.fontSize = style.fontSize; + textProbe.style.fontWeight = style.fontWeight; + textProbe.style.letterSpacing = style.letterSpacing; + textProbe.style.visibility = "hidden"; + document.body.appendChild(textProbe); + const textWidth = textProbe.getBoundingClientRect().width; + textProbe.remove(); + + return Math.ceil(textWidth + horizontalPadding); +} + +function hasCellOverflow(cell: HTMLTableCellElement): boolean { + if (cell.scrollWidth > cell.clientWidth + 1) { + return true; + } + + return Array.from(cell.children).some((child) => { + if (!(child instanceof HTMLElement)) { + return false; + } + return child.scrollWidth > child.clientWidth + 1; + }); +} + +export interface ResizableTableProps extends TableProps { + columnResizeKey: string; + minColumnWidth?: number; +} + +function ResizableTable(props: ResizableTableProps) { + const { columns, components, columnResizeKey, minColumnWidth = 72, scroll, sticky, ...tableProps } = props; + const [columnWidths, setColumnWidths] = useSavedState>( + `table-column-widths-${columnResizeKey}`, + {}, + ); + + const { resolvedColumns, requiredMinWidth } = useMemo(() => { + if (!columns) { + return { resolvedColumns: columns, requiredMinWidth: minColumnWidth }; + } + + let minWidthSum = 0; + + const withResize = (input: ColumnsType, parentId: string): ColumnsType => { + return input.map((column, index) => { + const id = columnIdentifier(column, index, parentId); + const columnWidth = + typeof columnWidths[id] === "number" + ? columnWidths[id] + : typeof column.width === "number" + ? column.width + : undefined; + + const columnChildren = (column as { children?: ColumnsType }).children; + const hasChildren = Array.isArray(columnChildren) && columnChildren.length > 0; + const nextColumn: ColumnsType[number] = { + ...column, + }; + + if (hasChildren) { + (nextColumn as { children?: ColumnsType }).children = withResize(columnChildren, id); + return nextColumn; + } + + const leafColumn = nextColumn as ColumnType; + const existingMinWidth = + typeof (leafColumn as { minWidth?: number }).minWidth === "number" + ? (leafColumn as { minWidth?: number }).minWidth + : undefined; + const effectiveMinWidth = Math.max(minColumnWidth, existingMinWidth ?? 0); + + if (typeof columnWidth === "number") { + leafColumn.width = Math.max(effectiveMinWidth, columnWidth); + } + (leafColumn as { minWidth?: number }).minWidth = effectiveMinWidth; + + minWidthSum += typeof leafColumn.width === "number" ? leafColumn.width : effectiveMinWidth; + + const originalOnHeaderCell = (column as ColumnType).onHeaderCell; + leafColumn.onHeaderCell = (col) => { + const existingProps = (originalOnHeaderCell?.(col as never) ?? {}) as Record; + return { + ...existingProps, + resizable: true, + onResizeStart: (event: ReactMouseEvent) => { + const startX = event.clientX; + const currentHeaderCell = event.currentTarget.closest("th"); + const baseWidth = Math.max( + minColumnWidth, + currentHeaderCell?.getBoundingClientRect().width ?? columnWidth ?? minColumnWidth, + ); + const originalCursor = document.body.style.cursor; + const originalUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const onMouseMove = (moveEvent: MouseEvent) => { + const nextWidth = Math.max(minColumnWidth, baseWidth + moveEvent.clientX - startX); + setColumnWidths((previous) => { + if (previous[id] === nextWidth) { + return previous; + } + return { + ...previous, + [id]: nextWidth, + }; + }); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = originalCursor; + document.body.style.userSelect = originalUserSelect; + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, + onResizeAutoFit: (event: ReactMouseEvent) => { + const currentHeaderCell = event.currentTarget.closest("th"); + const headerRow = currentHeaderCell?.parentElement; + if (!(currentHeaderCell instanceof HTMLTableCellElement) || !(headerRow instanceof HTMLTableRowElement)) { + return; + } + + const headerCells = Array.from(headerRow.cells); + const columnIndex = headerCells.indexOf(currentHeaderCell); + if (columnIndex === -1) { + return; + } + + const tableContainer = currentHeaderCell.closest(".ant-table-container"); + const currentWidth = Math.ceil(currentHeaderCell.getBoundingClientRect().width); + let nextWidth = measureCellAutoFitWidth(currentHeaderCell); + let hasOverflow = hasCellOverflow(currentHeaderCell); + + if (tableContainer) { + const bodyRows = Array.from(tableContainer.querySelectorAll("tbody tr")); + bodyRows.forEach((row) => { + if (!(row instanceof HTMLTableRowElement)) { + return; + } + const cell = row.cells.item(columnIndex); + if (cell) { + nextWidth = Math.max(nextWidth, measureCellAutoFitWidth(cell)); + hasOverflow = hasOverflow || hasCellOverflow(cell); + } + }); + } + + const autoFitWidth = Math.max(minColumnWidth, nextWidth); + const finalWidth = hasOverflow ? autoFitWidth : Math.max(minColumnWidth, Math.min(currentWidth, autoFitWidth)); + setColumnWidths((previous) => { + if (previous[id] === finalWidth) { + return previous; + } + return { + ...previous, + [id]: finalWidth, + }; + }); + }, + } as unknown as HTMLAttributes; + }; + + return nextColumn; + }); + }; + + return { + resolvedColumns: withResize(columns as ColumnsType, "root"), + requiredMinWidth: minWidthSum, + }; + }, [columns, columnWidths, minColumnWidth, setColumnWidths]); + + const mergedComponents = useMemo(() => { + return { + ...components, + header: { + ...components?.header, + cell: ResizableHeaderCell, + }, + }; + }, [components]); + + const resolvedScroll = useMemo(() => { + const nextScroll = { ...(scroll ?? {}) }; + if (nextScroll.x === undefined) { + nextScroll.x = requiredMinWidth; + } else if (typeof nextScroll.x === "number") { + nextScroll.x = Math.max(nextScroll.x, requiredMinWidth); + } + return nextScroll; + }, [requiredMinWidth, scroll]); + + return ( + + {...tableProps} + columns={resolvedColumns} + components={mergedComponents} + scroll={resolvedScroll} + sticky={sticky ?? true} + /> + ); +} + +export default ResizableTable; diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx index b465e6ce3..ab1b54b1b 100644 --- a/client/src/pages/filaments/edit.tsx +++ b/client/src/pages/filaments/edit.tsx @@ -66,6 +66,7 @@ export const FilamentEdit = () => { extra: selectedVendor?.extra ?? {}, } : undefined; + const watchedAllValues = Form.useWatch([], formProps.form); // Add the vendor_id field to the form if (formProps.initialValues) { @@ -100,8 +101,79 @@ export const FilamentEdit = () => { } }; + + const normalizeForCompare = (value: unknown): unknown => { + if (dayjs.isDayjs(value)) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value.map(normalizeForCompare); + } + if (value && typeof value === "object") { + const objectValue = value as Record; + return Object.keys(objectValue) + .sort() + .reduce>((acc, key) => { + const normalizedValue = normalizeForCompare(objectValue[key]); + if (normalizedValue !== undefined) { + acc[key] = normalizedValue; + } + return acc; + }, {}); + } + return value; + }; + + const toComparableState = (value: unknown): string => { + const normalized = normalizeForCompare(value) as Record | undefined; + const normalizedExtra = { ...(normalized?.extra as Record | undefined) }; + + return JSON.stringify({ + name: normalized?.name ?? "", + vendor_id: normalized?.vendor_id ?? null, + material: normalized?.material ?? "", + price: normalized?.price ?? null, + density: normalized?.density ?? null, + diameter: normalized?.diameter ?? null, + weight: normalized?.weight ?? null, + spool_weight: normalized?.spool_weight ?? null, + settings_extruder_temp: normalized?.settings_extruder_temp ?? null, + settings_bed_temp: normalized?.settings_bed_temp ?? null, + article_number: normalized?.article_number ?? "", + external_id: normalized?.external_id ?? "", + comment: normalized?.comment ?? "", + color_hex: normalized?.color_hex ?? "", + multi_color_direction: normalized?.multi_color_direction ?? "", + multi_color_hexes: colorType === "single" ? "" : (normalized?.multi_color_hexes ?? ""), + extra: normalizedExtra, + }); + }; + + const initialComparableState = useMemo( + () => (formProps.initialValues ? toComparableState(formProps.initialValues) : null), + [formProps.initialValues, colorType], + ); + const watchedComparableState = useMemo( + () => (watchedAllValues ? toComparableState(watchedAllValues) : null), + [watchedAllValues, colorType], + ); + const hasFormChanges = + initialComparableState !== null && watchedComparableState !== null && initialComparableState !== watchedComparableState; + const saveButtonState = { + ...saveButtonProps, + type: hasFormChanges ? ("primary" as const) : ("default" as const), + disabled: saveButtonProps.disabled || !hasFormChanges, + }; + return ( - + ( +
+ {defaultButtons} +
+ )} + > {contextHolder}
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index c693d9619..708a466d1 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -8,7 +8,7 @@ import { } from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; -import { Button, Dropdown, Table } from "antd"; +import { Button, Dropdown } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useMemo, useState } from "react"; @@ -24,6 +24,7 @@ import { SpoolIconColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; +import ResizableTable from "../../components/resizableTable"; import { useSpoolmanArticleNumbers, useSpoolmanFilamentNames, @@ -174,6 +175,7 @@ export const FilamentList = () => { tableState, sorter: true, }; + const hasActiveFilters = (filters?.length ?? 0) > 0; return ( ( @@ -188,7 +190,7 @@ export const FilamentList = () => { {t("printing.qrcode.selectButton")} - {defaultButtons} + )} > +
+ +
{`${t("filament.fields.registered")} ${ record?.registered ? dayjs.utc(record.registered).local().format("YYYY-MM-DD HH:mm:ss") : "-" @@ -111,10 +155,7 @@ export const FilamentShow = () => {
{t("filament.fields.vendor")}:{" "} {record?.vendor?.id ? ( - ) : ( diff --git a/client/src/pages/printing/filamentQrCodeExportDialog.tsx b/client/src/pages/printing/filamentQrCodeExportDialog.tsx index eca339430..88863943a 100644 --- a/client/src/pages/printing/filamentQrCodeExportDialog.tsx +++ b/client/src/pages/printing/filamentQrCodeExportDialog.tsx @@ -1,7 +1,8 @@ import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Typography, message } from "antd"; import TextArea from "antd/es/input/TextArea"; +import ResizableTable from "../../components/resizableTable"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { EntityType, useGetFields } from "../../utils/queryFields"; @@ -451,7 +452,8 @@ const FilamentQRCodeExportDialog = ({ filamentIds }: FilamentQRCodeExportDialogP /> setTemplateHelpOpen(false)}> - setTemplateHelpOpen(false)}> -
{ const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); const [messageApi, contextHolder] = message.useMessage(); const navigate = useNavigate(); const [searchValue, setSearchValue] = useState(""); + const [showColumns, setShowColumns] = useSavedState(`${namespace}-showColumns`, allColumns); const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = useTable({ - resource: "filament", - syncWithLocation: false, - pagination: { - mode: "server", - currentPage: 1, - pageSize: 50, - }, - sorters: { - mode: "server", - }, - filters: { - mode: "server", - }, - queryOptions: { - select(data) { - return { - total: data.total, - data: data.data.map(collapseFilament), - }; + resource: "filament", + syncWithLocation: false, + pagination: { + mode: "server", + currentPage: 1, + pageSize: 50, + }, + sorters: { + mode: "server", }, - }, - }); + filters: { + mode: "server", + }, + queryOptions: { + select(data) { + return { + total: data.total, + data: data.data.map(collapseFilament), + }; + }, + }, + }); const tableState: TableState = { sorters, filters, pagination: { currentPage: currentPage, pageSize }, + showColumns, }; const dataSource: IFilamentCollapsed[] = useMemo( @@ -122,6 +141,7 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin const isAllFilteredSelected = dataSource.every((filament) => selectedSet.has(filament.id)); const isSomeButNotAllFilteredSelected = dataSource.some((filament) => selectedSet.has(filament.id)) && !isAllFilteredSelected; + const hasActiveFilters = searchValue.trim().length > 0 || (filters?.length ?? 0) > 0; const commonProps = { t, @@ -179,6 +199,8 @@ const FilamentSelectModal = ({ description, initialSelectedIds, onExport, onPrin + + ({ + key: columnId, + label: getColumnLabel(columnId), + })), + selectedKeys: showColumns, + selectable: true, + multiple: true, + onDeselect: (info) => { + setShowColumns(info.selectedKeys as string[]); + }, + onSelect: (info) => { + setShowColumns(info.selectedKeys as string[]); + }, + }} + > + + +
-
value ?? 0, + }), FilteredQueryColumn({ ...commonProps, id: "vendor.name", diff --git a/client/src/pages/printing/spoolQrCodeExportDialog.tsx b/client/src/pages/printing/spoolQrCodeExportDialog.tsx index 1eefd2bd4..f805e74d7 100644 --- a/client/src/pages/printing/spoolQrCodeExportDialog.tsx +++ b/client/src/pages/printing/spoolQrCodeExportDialog.tsx @@ -1,7 +1,8 @@ import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Typography, message } from "antd"; import TextArea from "antd/es/input/TextArea"; +import ResizableTable from "../../components/resizableTable"; import { useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { EntityType, useGetFields } from "../../utils/queryFields"; @@ -474,7 +475,8 @@ Spool Weight: {filament.spool_weight} g /> setTemplateHelpOpen(false)}> -
setTemplateHelpOpen(false)}> -
{ const [selectedItems, setSelectedItems] = useState(initialSelectedIds ?? []); const [showArchived, setShowArchived] = useState(false); @@ -50,45 +65,45 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint } const navigate = useNavigate(); const [searchValue, setSearchValue] = useState(""); const [selectedArchivedMap, setSelectedArchivedMap] = useState>({}); + const [showColumns, setShowColumns] = useSavedState(`${namespace}-showColumns`, allColumns); const { tableProps, sorters, filters, setFilters, currentPage, pageSize, setCurrentPage, setPageSize } = useTable({ - resource: "spool", - meta: { - queryParams: { - ["allow_archived"]: showArchived, + resource: "spool", + meta: { + queryParams: { + ["allow_archived"]: showArchived, + }, }, - }, - syncWithLocation: false, - pagination: { - mode: "server", - currentPage: 1, - pageSize: 50, - }, - sorters: { - mode: "server", - }, - filters: { - mode: "server", - }, - queryOptions: { - select(data) { - return { - total: data.total, - data: data.data.map(collapseSpool), - }; + syncWithLocation: false, + pagination: { + mode: "server", + currentPage: 1, + pageSize: 50, }, - }, - }); + sorters: { + mode: "server", + }, + filters: { + mode: "server", + }, + queryOptions: { + select(data) { + return { + total: data.total, + data: data.data.map(collapseSpool), + }; + }, + }, + }); - // Store state in local storage const tableState: TableState = { sorters, filters, pagination: { currentPage: currentPage, pageSize }, + showColumns, }; - // Collapse the dataSource to a mutable list and add a filament_name field const dataSource: ISpoolCollapsed[] = useMemo( () => (tableProps.dataSource || []).map((record) => ({ ...record })), [tableProps.dataSource], @@ -139,7 +154,6 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint } setCurrentPage(1); }; - // Function to add/remove all filtered items from selected items const selectUnselectFiltered = (select: boolean) => { setSelectedItems((prevSelected) => { const nextSelected = new Set(prevSelected); @@ -154,17 +168,16 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint } }); }; - // Handler for selecting/unselecting individual items const handleSelectItem = (item: number) => { setSelectedItems((prevSelected) => prevSelected.includes(item) ? prevSelected.filter((selected) => selected !== item) : [...prevSelected, item], ); }; - // State for the select/unselect all checkbox const isAllFilteredSelected = dataSource.every((spool) => selectedSet.has(spool.id)); const isSomeButNotAllFilteredSelected = dataSource.some((spool) => selectedSet.has(spool.id)) && !isAllFilteredSelected; + const hasActiveFilters = searchValue.trim().length > 0 || (filters?.length ?? 0) > 0; const commonProps = { t, @@ -225,6 +238,8 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint } + + ({ + key: columnId, + label: getColumnLabel(columnId), + })), + selectedKeys: showColumns, + selectable: true, + multiple: true, + onDeselect: (info) => { + setShowColumns(info.selectedKeys as string[]); + }, + onSelect: (info) => { + setShowColumns(info.selectedKeys as string[]); + }, + }} + > + + +
{ setShowArchived(e.target.checked); if (!e.target.checked) { - // Remove archived spools from selected items setSelectedItems((prevSelected) => prevSelected.filter((selected) => selectedArchivedMap[selected] !== true), ); @@ -312,7 +348,8 @@ const SpoolSelectModal = ({ description, initialSelectedIds, onExport, onPrint }
-
-
{ } return undefined; }, [selectedFilament]); + const watchedAllValues = Form.useWatch([], form); // Override the form's onFinish method to stringify the extra fields const originalOnFinish = formProps.onFinish; @@ -246,8 +247,72 @@ export const SpoolEdit = () => { } }, [initialUsedWeight]); + const normalizeForCompare = (value: unknown): unknown => { + if (dayjs.isDayjs(value)) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value.map(normalizeForCompare); + } + if (value && typeof value === "object") { + const objectValue = value as Record; + return Object.keys(objectValue) + .sort() + .reduce>((acc, key) => { + const normalizedValue = normalizeForCompare(objectValue[key]); + if (normalizedValue !== undefined) { + acc[key] = normalizedValue; + } + return acc; + }, {}); + } + return value; + }; + + const toComparableState = (value: unknown): string => { + const normalized = normalizeForCompare(value) as Record | undefined; + const normalizedExtra = { ...(normalized?.extra as Record | undefined) }; + + return JSON.stringify({ + first_used: normalized?.first_used ?? null, + last_used: normalized?.last_used ?? null, + filament_id: normalized?.filament_id ?? null, + price: normalized?.price ?? null, + initial_weight: normalized?.initial_weight ?? null, + spool_weight: normalized?.spool_weight ?? null, + used_weight: normalized?.used_weight ?? null, + location: normalized?.location ?? "", + lot_nr: normalized?.lot_nr ?? "", + comment: normalized?.comment ?? "", + extra: normalizedExtra, + }); + }; + + const initialComparableState = useMemo( + () => (formProps.initialValues ? toComparableState(formProps.initialValues) : null), + [formProps.initialValues], + ); + const watchedComparableState = useMemo( + () => (watchedAllValues ? toComparableState(watchedAllValues) : null), + [watchedAllValues], + ); + const hasFormChanges = + initialComparableState !== null && watchedComparableState !== null && initialComparableState !== watchedComparableState; + const saveButtonState = { + ...saveButtonProps, + type: hasFormChanges ? ("primary" as const) : ("default" as const), + disabled: saveButtonProps.disabled || !hasFormChanges, + }; + return ( - + ( +
+ {defaultButtons} +
+ )} + > {contextHolder} diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 23260145e..2aa298c0e 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -10,7 +10,7 @@ import { } from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; -import { Button, Dropdown, Modal, Table } from "antd"; +import { Button, Dropdown, Modal } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useCallback, useMemo, useState } from "react"; @@ -27,6 +27,7 @@ import { SpoolIconColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; +import ResizableTable from "../../components/resizableTable"; import { useSpoolmanFilamentFilter, useSpoolmanLocations, @@ -255,6 +256,7 @@ export const SpoolList = () => { tableState, sorter: true, }; + const hasActiveFilters = (filters?.length ?? 0) > 0; return ( { {showArchived ? t("buttons.hideArchived") : t("buttons.showArchived")}
{ id: "location", i18ncat: "spool", filterValueQuery: useSpoolmanLocations(), + emptyFilterLabel: "", width: 120, }), FilteredQueryColumn({ diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index 2101c480d..a7d559f17 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -1,9 +1,10 @@ -import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; +import { DeleteOutlined, EditOutlined, InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; import { DateField, Show, TextField } from "@refinedev/antd"; -import { useInvalidate, useShow, useTranslate } from "@refinedev/core"; +import { useDelete, useInvalidate, useShow, useTranslate } from "@refinedev/core"; import { Button, Col, Modal, Row, Typography } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; +import { useNavigate } from "react-router"; import { ExtraFieldDisplay } from "../../components/extraFields"; import ColorHexPreview from "../../components/colorHexPreview"; import { NumberFieldUnit } from "../../components/numberField"; @@ -23,9 +24,11 @@ const { confirm } = Modal; export const SpoolShow = () => { const t = useTranslate(); + const navigate = useNavigate(); const extraFields = useGetFields(EntityType.spool); const currencyFormatter = useCurrencyFormatter(); const invalidate = useInvalidate(); + const { mutate: deleteSpoolMutation } = useDelete(); const { query } = useShow({ liveMode: "auto", @@ -42,10 +45,8 @@ export const SpoolShow = () => { return currencyFormatter.format(price); }; - // Provides the function to open the spool adjustment modal and the modal component itself const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal(); - // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpool, archive: boolean) => { await setSpoolArchived(spool, archive); invalidate({ @@ -59,7 +60,6 @@ export const SpoolShow = () => { if (spool === undefined) { return; } - // If the spool has no remaining weight, archive it immediately since it's likely not a mistake if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) { await archiveSpool(spool, true); } else { @@ -76,6 +76,35 @@ export const SpoolShow = () => { } }; + const deleteSpoolPopup = (spool: ISpool | undefined) => { + if (!spool) { + return; + } + confirm({ + title: t("buttons.confirm"), + content: `${t("buttons.delete")} #${spool.id}?`, + okText: t("buttons.delete"), + okButtonProps: { danger: true }, + cancelText: t("buttons.cancel"), + onOk: () => + new Promise((resolve, reject) => { + deleteSpoolMutation( + { + resource: "spool", + id: spool.id, + }, + { + onSuccess: () => { + navigate("/spool"); + resolve(); + }, + onError: () => reject(new Error("delete failed")), + }, + ); + }), + }); + }; + const formatFilament = (item: IFilament) => { let vendorPrefix = ""; if (item.vendor) { @@ -97,8 +126,8 @@ export const SpoolShow = () => { record?.filament.multi_color_hexes && record.filament.multi_color_direction === "longitudinal" ? "Longitudinal Multi" : record?.filament.multi_color_hexes - ? "Coextruded Multi" - : null; + ? "Coextruded Multi" + : null; const formatTitle = (item: ISpool) => { return t("spool.titles.show_title", { @@ -112,7 +141,7 @@ export const SpoolShow = () => { ( + headerButtons={() => ( <> - {record?.archived ? ( - - ) : ( - - )} - - {defaultButtons} + {spoolAdjustModal} )} > +
+ {record?.archived ? ( + + ) : ( + + )} + +
{`${t("spool.fields.registered")} ${ record?.registered ? dayjs.utc(record.registered).local().format("YYYY-MM-DD HH:mm:ss") : "-" @@ -154,7 +189,7 @@ export const SpoolShow = () => {
{record && ( - <a href={filamentURL(record.filament)}>{`${formatFilament(record.filament)} Filament`}</a> + <a className="app-link" href={filamentURL(record.filament)}>{`${formatFilament(record.filament)} Filament`}</a> )} {t("filament.fields.color_hex")} @@ -173,7 +208,7 @@ export const SpoolShow = () => {
{t("filament.fields.vendor")}:{" "} {record?.filament.vendor?.id ? ( - {record.filament.vendor.name} + {record.filament.vendor.name} ) : ( {record?.filament.vendor?.name ?? "-"} )} diff --git a/client/src/pages/vendors/edit.tsx b/client/src/pages/vendors/edit.tsx index bd0caaed2..7eb1837ba 100644 --- a/client/src/pages/vendors/edit.tsx +++ b/client/src/pages/vendors/edit.tsx @@ -23,6 +23,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo import { useVendorLogoManifest } from "../../components/otherModels"; import VendorLogo from "../../components/vendorLogo"; import { EntityType, useGetFields } from "../../utils/queryFields"; +import { getAPIURL } from "../../utils/url"; import { suggestVendorLogoOptions, suggestVendorLogoPaths } from "../../utils/vendorLogo"; import { IVendor, IVendorParsedExtras } from "./model"; @@ -40,6 +41,7 @@ export const VendorEdit = () => { const [hasChanged, setHasChanged] = useState(false); const [allowAutoSuggest, setAllowAutoSuggest] = useState(true); const [isSyncingLogoPack, setIsSyncingLogoPack] = useState(false); + const [isConvertingPrintLogo, setIsConvertingPrintLogo] = useState(false); const [savedComparableState, setSavedComparableState] = useState(null); const extraFields = useGetFields(EntityType.vendor); const logoManifest = useVendorLogoManifest(); @@ -128,6 +130,22 @@ export const VendorEdit = () => { ); + const logoSuggestionsLabel = ( + <> + {t("vendor.fields.logo_suggestions")} {" "} + + + + + ); + const printLogoSuggestionsLabel = ( + <> + {t("vendor.fields.print_logo_suggestions")} {" "} + + + + + ); const webSuggestions = watchedName && logoManifest.data ? suggestVendorLogoOptions(watchedName, logoManifest.data, "web") : []; const printSuggestions = @@ -207,6 +225,44 @@ export const VendorEdit = () => { return toComparableState(formProps.initialValues); }, [formProps.initialValues]); + const convertWebLogoToPrint = async () => { + if (!logoUrlValue) { + messageApi.warning(t("vendor.form.logo_convert_requires_web_logo")); + return; + } + + setIsConvertingPrintLogo(true); + try { + const response = await fetch(getAPIURL() + "/vendor/logo-pack/convert-web-to-print", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + logo_url: logoUrlValue, + vendor_name: watchedName ?? null, + }), + }); + const body = (await response.json()) as { + print_logo_url?: string; + message?: string; + }; + + if (!response.ok || !body.print_logo_url) { + throw new Error(body.message ?? t("vendor.form.logo_convert_error")); + } + + setAllowAutoSuggest(false); + formProps.form?.setFieldValue(["extra", "print_logo_url"], body.print_logo_url); + await logoManifest.refetch(); + messageApi.success(body.message ?? t("vendor.form.logo_convert_success")); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : t("vendor.form.logo_convert_error")); + } finally { + setIsConvertingPrintLogo(false); + } + }; + useEffect(() => { if (initialComparableState !== null) { setSavedComparableState(initialComparableState); @@ -230,7 +286,7 @@ export const VendorEdit = () => { const syncLogoPackFromGithub = async () => { setIsSyncingLogoPack(true); try { - const response = await fetch("/api/v1/vendor/logo-pack/sync-from-github", { + const response = await fetch(getAPIURL() + "/vendor/logo-pack/sync-from-github", { method: "POST", }); const body = (await response.json()) as { @@ -307,20 +363,7 @@ export const VendorEdit = () => { ( -
+
{defaultButtons}
)} @@ -412,7 +455,7 @@ export const VendorEdit = () => { - + { />
+ + + + +
* { margin-inline-start: 0 !important; } + + +a.app-link, +a.app-link:visited, +.ant-typography a.app-link, +.ant-typography a.app-link:visited { + color: #d07a36 !important; + text-decoration: none; +} + +a.app-link:hover, +.ant-typography a.app-link:hover { + color: #e08a46 !important; + text-decoration: underline; +} + +.app-link-button { + background: none; + border: none; + color: #d07a36 !important; + cursor: pointer; + padding: 0; + font: inherit; +} + +.app-link-button:hover { + color: #e08a46 !important; + text-decoration: underline; +} + + +.show-floating-actions, +.floating-form-actions { + position: fixed; + right: var(--floating-actions-right); + bottom: var(--floating-actions-bottom); + z-index: 1200; + display: flex; + align-items: flex-end; + gap: 8px; + padding: var(--floating-actions-padding-y) var(--floating-actions-padding-x); + border-radius: 10px; + background: rgba(17, 17, 17, 0.88); + backdrop-filter: blur(4px); +} + +@media (max-width: 768px) { + .show-floating-actions, + .floating-form-actions { + right: var(--floating-actions-right); + bottom: var(--floating-actions-bottom); + flex-wrap: wrap; + justify-content: flex-end; + } +} + + +.resizable-table-header-cell { + position: relative; +} + +.resizable-table-header-handle { + position: absolute; + top: 0; + right: -4px; + width: 8px; + height: 100%; + cursor: col-resize; + z-index: 2; +} + +.resizable-table-header-handle::after { + content: ""; + position: absolute; + top: 18%; + bottom: 18%; + left: 50%; + transform: translateX(-50%); + width: 1px; + background: rgba(255, 255, 255, 0.2); + transition: background-color 0.15s ease; +} + +.resizable-table-header-handle:hover::after { + background: #d07a36; +} diff --git a/client/src/utils/parsing.tsx b/client/src/utils/parsing.tsx index 34e3739ca..bc28bc228 100644 --- a/client/src/utils/parsing.tsx +++ b/client/src/utils/parsing.tsx @@ -111,7 +111,7 @@ export function enrichText(text: string | undefined) { const elements = parts.map((part, index) => { if (part.match(urlRegex)) { return ( - + {part} ); diff --git a/client/src/utils/vendorLogo.ts b/client/src/utils/vendorLogo.ts index 564a45c12..9f1d6dd58 100644 --- a/client/src/utils/vendorLogo.ts +++ b/client/src/utils/vendorLogo.ts @@ -146,7 +146,7 @@ export function getVendorLogoCandidates(vendor: IVendor | undefined, usePrintLog const extraLogo = parseExtraString(vendor.extra?.logo_url); const extraPrintLogo = parseExtraString(vendor.extra?.print_logo_url); - const customLogo = usePrintLogo ? extraPrintLogo ?? extraLogo : extraLogo; + const customLogo = usePrintLogo ? extraPrintLogo : extraLogo; const candidates: string[] = []; const customUrl = normalizeUrl(customLogo); @@ -167,7 +167,7 @@ export function getVendorLogoCandidates(vendor: IVendor | undefined, usePrintLog `${basePath}/vendor-logos/${slug}.png`, `${basePath}/vendor-logos/${slug}-web.png`, ]; - candidates.push(...(usePrintLogo ? [...printCandidates, ...webCandidates] : [...webCandidates, ...printCandidates])); + candidates.push(...(usePrintLogo ? printCandidates : [...webCandidates, ...printCandidates])); } return [...new Set(candidates)]; diff --git a/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py b/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py new file mode 100644 index 000000000..ec385e33e --- /dev/null +++ b/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py @@ -0,0 +1,37 @@ +"""add index on spool.filament_id. + +Revision ID: b5f9c2e31a1b +Revises: 415a8f855e14 +Create Date: 2026-02-20 16:05:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b5f9c2e31a1b" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_indexes = inspector.get_indexes("spool") + + has_filament_id_index = any( + [column.lower() for column in (index.get("column_names") or [])] == ["filament_id"] for index in existing_indexes + ) + if not has_filament_id_index: + op.create_index("ix_spool_filament_id", "spool", ["filament_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + index_names = {index["name"] for index in inspector.get_indexes("spool")} + if "ix_spool_filament_id" in index_names: + op.drop_index("ix_spool_filament_id", table_name="spool") diff --git a/pyproject.toml b/pyproject.toml index f6229db47..415173d75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "httpx~=0.28", "hishel~=0.1", "aiomysql[rsa]~=0.3", + "Pillow~=11.0", ] requires-python = ">=3.10" diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 6f8b3c9b3..c789d9a33 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -309,6 +309,46 @@ async def find( examples=["0", "1,2,3"], ), ] = None, + filament_id: Annotated[ + str | None, + Query(alias="id", title="Filament ID", description="Partial match against filament ID values."), + ] = None, + price: Annotated[ + str | None, + Query(title="Price", description="Partial match against filament price values as text."), + ] = None, + density: Annotated[ + str | None, + Query(title="Density", description="Partial match against density values as text."), + ] = None, + diameter: Annotated[ + str | None, + Query(title="Diameter", description="Partial match against diameter values as text."), + ] = None, + weight: Annotated[ + str | None, + Query(title="Weight", description="Partial match against filament weight values as text."), + ] = None, + spool_weight: Annotated[ + str | None, + Query(title="Spool Weight", description="Partial match against spool-weight values as text."), + ] = None, + settings_extruder_temp: Annotated[ + str | None, + Query(title="Extruder Temperature", description="Partial match against extruder-temperature values."), + ] = None, + settings_bed_temp: Annotated[ + str | None, + Query(title="Bed Temperature", description="Partial match against bed-temperature values."), + ] = None, + registered: Annotated[ + str | None, + Query(title="Registered", description="Partial match against registration timestamps."), + ] = None, + comment: Annotated[ + str | None, + Query(title="Comment", description="Partial case-insensitive match against filament comments."), + ] = None, external_id: Annotated[ str | None, Query( @@ -370,10 +410,20 @@ async def find( spool_count=spool_counts, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_id=vendor_ids, + filament_id=filament_id, name=name, material=material, article_number=article_number, + price=price, + density=density, + diameter=diameter, + weight=weight, + spool_weight=spool_weight, + settings_extruder_temp=settings_extruder_temp, + settings_bed_temp=settings_bed_temp, + registered=registered, external_id=external_id, + comment=comment, sort_by=sort_by, limit=limit, offset=offset, @@ -389,6 +439,19 @@ async def find( ) +@router.get( + "/name", + name="Find filament names", + description="Get distinct filament names.", + response_model_exclude_none=True, +) +async def find_names( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_names(db=db) + + @router.get( "/spool-count", name="Find filament spool counts", diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..0c7641de1 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -247,6 +247,46 @@ async def find( ), ), ] = None, + spool_id: Annotated[ + str | None, + Query(alias="id", title="Spool ID", description="Partial match against spool ID values."), + ] = None, + price: Annotated[ + str | None, + Query(title="Price", description="Partial match against spool price values as text."), + ] = None, + used_weight: Annotated[ + str | None, + Query(title="Used Weight", description="Partial match against used weight values as text."), + ] = None, + remaining_weight: Annotated[ + str | None, + Query(title="Remaining Weight", description="Partial match against remaining weight values as text."), + ] = None, + used_length: Annotated[ + str | None, + Query(title="Used Length", description="Partial match against used length values as text."), + ] = None, + remaining_length: Annotated[ + str | None, + Query(title="Remaining Length", description="Partial match against remaining length values as text."), + ] = None, + first_used: Annotated[ + str | None, + Query(title="First Used", description="Partial match against first-used timestamps."), + ] = None, + last_used: Annotated[ + str | None, + Query(title="Last Used", description="Partial match against last-used timestamps."), + ] = None, + registered: Annotated[ + str | None, + Query(title="Registered", description="Partial match against registration timestamps."), + ] = None, + comment: Annotated[ + str | None, + Query(title="Comment", description="Partial case-insensitive match against spool comments."), + ] = None, allow_archived: Annotated[ bool, Query(title="Allow Archived", description="Whether to include archived spools in the search results."), @@ -294,6 +334,16 @@ async def find( vendor_id=filament_vendor_ids, location=location, lot_nr=lot_nr, + spool_id=spool_id, + price=price, + used_weight=used_weight, + remaining_weight=remaining_weight, + used_length=used_length, + remaining_length=remaining_length, + first_used=first_used, + last_used=last_used, + registered=registered, + comment=comment, allow_archived=allow_archived, sort_by=sort_by, limit=limit, diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index f06f5dda0..3a5d0dfc1 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -14,7 +14,7 @@ from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict -from spoolman.vendor_logos import sync_logo_pack_from_github_if_needed +from spoolman.vendor_logos import convert_web_logo_to_print_logo, sync_logo_pack_from_github_if_needed from spoolman.ws import websocket_manager router = APIRouter( @@ -80,6 +80,24 @@ class VendorLogoPackSyncResult(BaseModel): synced_at_utc: str | None = Field(None, description="UTC timestamp of last successful pack sync.") +class VendorLogoConvertRequest(BaseModel): + logo_url: str = Field(description="Source web logo URL or local logo path.") + vendor_name: str | None = Field(None, max_length=64, description="Optional vendor name for filename slug.") + + @field_validator("logo_url") + @classmethod + def validate_logo_url(cls: type["VendorLogoConvertRequest"], value: str) -> str: + trimmed = value.strip() + if trimmed == "": + raise ValueError("Logo URL is required.") + return trimmed + + +class VendorLogoConvertResult(BaseModel): + print_logo_url: str = Field(description="Generated print logo URL.") + message: str = Field(description="Result summary.") + + @router.get( "", name="Find vendor", @@ -118,6 +136,22 @@ async def find( ), ), ] = None, + vendor_id: Annotated[ + str | None, + Query(alias="id", title="Vendor ID", description="Partial match against vendor ID values."), + ] = None, + registered: Annotated[ + str | None, + Query(title="Registered", description="Partial match against registration timestamps."), + ] = None, + empty_spool_weight: Annotated[ + str | None, + Query(title="Empty Spool Weight", description="Partial match against empty spool weight values as text."), + ] = None, + comment: Annotated[ + str | None, + Query(title="Comment", description="Partial case-insensitive match against vendor comments."), + ] = None, sort: Annotated[ str | None, Query( @@ -144,6 +178,10 @@ async def find( db=db, name=name, external_id=external_id, + vendor_id=vendor_id, + registered=registered, + empty_spool_weight=empty_spool_weight, + comment=comment, sort_by=sort_by, limit=limit, offset=offset, @@ -200,6 +238,24 @@ async def sync_logo_pack_from_github() -> VendorLogoPackSyncResult: ) +@router.post( + "/logo-pack/convert-web-to-print", + name="Convert vendor web logo to print logo", + description="Converts a web logo into a black-and-white print logo stored in runtime vendor logos.", + response_model=VendorLogoConvertResult, +) +async def convert_web_logo_to_print(body: VendorLogoConvertRequest) -> VendorLogoConvertResult | JSONResponse: + try: + print_logo_url = await asyncio.to_thread(convert_web_logo_to_print_logo, body.logo_url, body.vendor_name) + except (ValueError, RuntimeError, OSError) as exc: + return JSONResponse(status_code=400, content=Message(message=str(exc)).model_dump()) + + return VendorLogoConvertResult( + print_logo_url=print_logo_url, + message="Generated print logo from web logo.", + ) + + @router.get( "/{vendor_id}", name="Get vendor", diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 17bc18362..5cc0ddfbe 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -15,6 +15,7 @@ from spoolman.database.utils import ( SortOrder, add_where_clause_int_in, + add_where_clause_number_opt, add_where_clause_int_opt, add_where_clause_str, add_where_clause_str_opt, @@ -103,7 +104,17 @@ async def find( name: str | None = None, material: str | None = None, article_number: str | None = None, + filament_id: str | None = None, + price: str | None = None, + density: str | None = None, + diameter: str | None = None, + weight: str | None = None, + spool_weight: str | None = None, + settings_extruder_temp: str | None = None, + settings_bed_temp: str | None = None, + registered: str | None = None, external_id: str | None = None, + comment: str | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -115,64 +126,111 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ + spool_count_subquery = ( + select(models.Spool.filament_id.label("filament_id"), func.count(models.Spool.id).label("spool_count")) + .group_by(models.Spool.filament_id) + .subquery() + ) + spool_count_expr = func.coalesce(spool_count_subquery.c.spool_count, 0) + stmt = ( - select(models.Filament) + select(models.Filament, spool_count_expr.label("spool_count")) .options(contains_eager(models.Filament.vendor)) .join(models.Filament.vendor, isouter=True) + .join(spool_count_subquery, spool_count_subquery.c.filament_id == models.Filament.id, isouter=True) ) stmt = add_where_clause_int_in(stmt, models.Filament.id, ids) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_number_opt(stmt, models.Filament.id, filament_id) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) + stmt = add_where_clause_number_opt(stmt, models.Filament.price, price) + stmt = add_where_clause_number_opt(stmt, models.Filament.density, density) + stmt = add_where_clause_number_opt(stmt, models.Filament.diameter, diameter) + stmt = add_where_clause_number_opt(stmt, models.Filament.weight, weight) + stmt = add_where_clause_number_opt(stmt, models.Filament.spool_weight, spool_weight) + stmt = add_where_clause_number_opt(stmt, models.Filament.settings_extruder_temp, settings_extruder_temp) + stmt = add_where_clause_number_opt(stmt, models.Filament.settings_bed_temp, settings_bed_temp) + stmt = add_where_clause_number_opt(stmt, models.Filament.registered, registered) stmt = add_where_clause_str_opt(stmt, models.Filament.external_id, external_id) + stmt = add_where_clause_str_opt(stmt, models.Filament.comment, comment) if has_spools is not None: - has_spool_subquery = select(models.Spool.id).where(models.Spool.filament_id == models.Filament.id).exists() - stmt = stmt.where(has_spool_subquery if has_spools else ~has_spool_subquery) + stmt = stmt.where(spool_count_expr > 0 if has_spools else spool_count_expr == 0) if spool_count is not None: if isinstance(spool_count, int): spool_count = [spool_count] - spool_count_subquery = ( - select(func.count(models.Spool.id)) - .where(models.Spool.filament_id == models.Filament.id) - .scalar_subquery() - ) - stmt = stmt.where(spool_count_subquery.in_(spool_count)) + stmt = stmt.where(spool_count_expr.in_(spool_count)) total_count = None - if limit is not None: - total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True) - total_count = (await db.execute(total_count_stmt)).scalar() - - stmt = stmt.offset(offset).limit(limit) - if sort_by is not None: for fieldstr, order in sort_by.items(): - field = parse_nested_field(models.Filament, fieldstr) + if fieldstr == "spool_count": + field = spool_count_expr + else: + field = parse_nested_field(models.Filament, fieldstr) if order == SortOrder.ASC: stmt = stmt.order_by(field.asc()) elif order == SortOrder.DESC: stmt = stmt.order_by(field.desc()) + if limit is not None: + total_count_stmt = select(func.count(models.Filament.id)).select_from(models.Filament) + if vendor_name is not None: + total_count_stmt = total_count_stmt.join(models.Filament.vendor, isouter=True) + + total_count_stmt = add_where_clause_int_in(total_count_stmt, models.Filament.id, ids) + total_count_stmt = add_where_clause_int_opt(total_count_stmt, models.Filament.vendor_id, vendor_id) + total_count_stmt = add_where_clause_str(total_count_stmt, models.Vendor.name, vendor_name) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.id, filament_id) + total_count_stmt = add_where_clause_str_opt(total_count_stmt, models.Filament.name, name) + total_count_stmt = add_where_clause_str_opt(total_count_stmt, models.Filament.material, material) + total_count_stmt = add_where_clause_str_opt(total_count_stmt, models.Filament.article_number, article_number) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.price, price) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.density, density) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.diameter, diameter) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.weight, weight) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.spool_weight, spool_weight) + total_count_stmt = add_where_clause_number_opt( + total_count_stmt, + models.Filament.settings_extruder_temp, + settings_extruder_temp, + ) + total_count_stmt = add_where_clause_number_opt( + total_count_stmt, + models.Filament.settings_bed_temp, + settings_bed_temp, + ) + total_count_stmt = add_where_clause_number_opt(total_count_stmt, models.Filament.registered, registered) + total_count_stmt = add_where_clause_str_opt(total_count_stmt, models.Filament.external_id, external_id) + total_count_stmt = add_where_clause_str_opt(total_count_stmt, models.Filament.comment, comment) + + if has_spools is not None: + has_spool_subquery = select(models.Spool.id).where(models.Spool.filament_id == models.Filament.id).exists() + total_count_stmt = total_count_stmt.where(has_spool_subquery if has_spools else ~has_spool_subquery) + + if spool_count is not None: + spool_count_scalar_subquery = ( + select(func.count(models.Spool.id)) + .where(models.Spool.filament_id == models.Filament.id) + .scalar_subquery() + ) + total_count_stmt = total_count_stmt.where(spool_count_scalar_subquery.in_(spool_count)) + + total_count = (await db.execute(total_count_stmt)).scalar() + stmt = stmt.offset(offset).limit(limit) + rows = await db.execute( stmt, execution_options={"populate_existing": True}, ) - result = list(rows.unique().scalars().all()) - - if result: - filament_ids = [item.id for item in result] - spool_count_rows = await db.execute( - select(models.Spool.filament_id, func.count(models.Spool.id)) - .where(models.Spool.filament_id.in_(filament_ids)) - .group_by(models.Spool.filament_id), - ) - spool_counts = {filament_id: count for filament_id, count in spool_count_rows.all()} - for item in result: - setattr(item, "spool_count", spool_counts.get(item.id, 0)) + result: list[models.Filament] = [] + for item, item_spool_count in rows.unique().all(): + setattr(item, "spool_count", int(item_spool_count or 0)) + result.append(item) if total_count is None: total_count = len(result) @@ -237,6 +295,21 @@ async def find_materials( return [row[0] for row in rows.all() if row[0] is not None] +async def find_names( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament names by searching for distinct values in the filament table.""" + stmt = ( + select(models.Filament.name) + .where(models.Filament.name.is_not(None)) + .distinct() + .order_by(models.Filament.name.asc()) + ) + rows = await db.execute(stmt) + return [row[0] for row in rows.all() if row[0] is not None] + + async def find_article_numbers( *, db: AsyncSession, @@ -252,15 +325,21 @@ async def find_spool_counts( db: AsyncSession, ) -> list[int]: """Find distinct spool counts per filament.""" - per_filament_count = ( - select(models.Filament.id, func.count(models.Spool.id).label("spool_count")) - .join(models.Spool, models.Spool.filament_id == models.Filament.id, isouter=True) - .group_by(models.Filament.id) - .subquery() + spool_counts_stmt = select(func.count(models.Spool.id)).group_by(models.Spool.filament_id) + spool_count_rows = await db.execute(spool_counts_stmt) + spool_counts = {int(row[0]) for row in spool_count_rows.all()} + + filament_without_spool_exists_stmt = ( + select(models.Filament.id) + .where(~select(models.Spool.id).where(models.Spool.filament_id == models.Filament.id).exists()) + .limit(1) ) - stmt = select(per_filament_count.c.spool_count).distinct().order_by(per_filament_count.c.spool_count.asc()) - rows = await db.execute(stmt) - return [int(row[0]) for row in rows.all()] + filament_without_spool_exists = (await db.execute(filament_without_spool_exists_stmt)).scalar() is not None + + if filament_without_spool_exists: + spool_counts.add(0) + + return sorted(spool_counts) async def find_by_color( diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..7f3dcff2e 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -16,6 +16,7 @@ from spoolman.database.utils import ( SortOrder, add_where_clause_int, + add_where_clause_number_opt, add_where_clause_int_opt, add_where_clause_str, add_where_clause_str_opt, @@ -121,6 +122,16 @@ async def find( # noqa: C901, PLR0912 vendor_id: int | Sequence[int] | None = None, location: str | None = None, lot_nr: str | None = None, + spool_id: str | None = None, + price: str | None = None, + used_weight: str | None = None, + remaining_weight: str | None = None, + used_length: str | None = None, + remaining_length: str | None = None, + first_used: str | None = None, + last_used: str | None = None, + registered: str | None = None, + comment: str | None = None, allow_archived: bool = False, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, @@ -140,6 +151,19 @@ async def find( # noqa: C901, PLR0912 .options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor)) ) + price_expr = coalesce(models.Spool.price, models.Filament.price) + remaining_weight_expr = coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight + remaining_length_expr = ( + remaining_weight_expr + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter) + ) + used_length_expr = ( + models.Spool.used_weight + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter) + ) + stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id) stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) @@ -147,6 +171,16 @@ async def find( # noqa: C901, PLR0912 stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr) + stmt = add_where_clause_number_opt(stmt, models.Spool.id, spool_id) + stmt = add_where_clause_number_opt(stmt, price_expr, price) + stmt = add_where_clause_number_opt(stmt, models.Spool.used_weight, used_weight) + stmt = add_where_clause_number_opt(stmt, remaining_weight_expr, remaining_weight) + stmt = add_where_clause_number_opt(stmt, used_length_expr, used_length) + stmt = add_where_clause_number_opt(stmt, remaining_length_expr, remaining_length) + stmt = add_where_clause_number_opt(stmt, models.Spool.first_used, first_used) + stmt = add_where_clause_number_opt(stmt, models.Spool.last_used, last_used) + stmt = add_where_clause_number_opt(stmt, models.Spool.registered, registered) + stmt = add_where_clause_str_opt(stmt, models.Spool.comment, comment) if not allow_archived: # Since the archived field is nullable, and default is false, we need to check for both false or null @@ -169,26 +203,16 @@ async def find( # noqa: C901, PLR0912 for fieldstr, order in sort_by.items(): sorts = [] if fieldstr == "remaining_weight": - sorts.append(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) + sorts.append(remaining_weight_expr) elif fieldstr == "remaining_length": - # Simplified weight -> length formula. Absolute value is not correct but the proportionality is still - # kept, which means the sort order is correct. - sorts.append( - (coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) + sorts.append(remaining_length_expr) elif fieldstr == "used_length": - sorts.append( - models.Spool.used_weight - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) + sorts.append(used_length_expr) elif fieldstr == "filament.combined_name": sorts.append(models.Vendor.name) sorts.append(models.Filament.name) elif fieldstr == "price": - sorts.append(coalesce(models.Spool.price, models.Filament.price)) + sorts.append(price_expr) else: sorts.append(parse_nested_field(models.Spool, fieldstr)) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 2d8776c00..41de9d4ec 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -85,6 +85,27 @@ def add_where_clause_str( return stmt +def add_where_clause_number_opt( + stmt: Select, + field: attributes.InstrumentedAttribute[Any] | Any, + value: str | None, +) -> Select: + """Add a where clause for numeric/date-like fields by searching their string representation.""" + if value is not None: + conditions = [] + for value_part in value.split(","): + term = value_part.strip() + if len(term) == 0: + conditions.append(field.is_(None)) + elif len(term) >= 2 and term[0] == '"' and term[-1] == '"': + conditions.append(sqlalchemy.cast(field, sqlalchemy.String) == term[1:-1]) + else: + conditions.append(sqlalchemy.cast(field, sqlalchemy.String).ilike(f"%{term}%")) + + stmt = stmt.where(sqlalchemy.or_(*conditions)) + return stmt + + def add_where_clause_int( stmt: Select, field: attributes.InstrumentedAttribute[int], diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index f2e83018e..75cbbd33f 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -9,7 +9,7 @@ from spoolman.api.v1.models import EventType, Vendor, VendorEvent from spoolman.database import models -from spoolman.database.utils import SortOrder, add_where_clause_str, add_where_clause_str_opt +from spoolman.database.utils import SortOrder, add_where_clause_number_opt, add_where_clause_str, add_where_clause_str_opt from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager @@ -53,6 +53,10 @@ async def find( db: AsyncSession, name: str | None = None, external_id: str | None = None, + vendor_id: str | None = None, + registered: str | None = None, + empty_spool_weight: str | None = None, + comment: str | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -65,6 +69,10 @@ async def find( stmt = add_where_clause_str(stmt, models.Vendor.name, name) stmt = add_where_clause_str_opt(stmt, models.Vendor.external_id, external_id) + stmt = add_where_clause_number_opt(stmt, models.Vendor.id, vendor_id) + stmt = add_where_clause_number_opt(stmt, models.Vendor.registered, registered) + stmt = add_where_clause_number_opt(stmt, models.Vendor.empty_spool_weight, empty_spool_weight) + stmt = add_where_clause_str_opt(stmt, models.Vendor.comment, comment) total_count = None diff --git a/spoolman/vendor_logos.py b/spoolman/vendor_logos.py index 99ded7cbf..ce82d0ee4 100644 --- a/spoolman/vendor_logos.py +++ b/spoolman/vendor_logos.py @@ -5,15 +5,20 @@ import hashlib import json import os +import re import shutil import tarfile import tempfile from dataclasses import dataclass from datetime import datetime, timezone +from io import BytesIO from pathlib import Path -from urllib.parse import quote +from uuid import uuid4 +from urllib.parse import quote, urlparse from urllib.request import Request, urlopen +from PIL import Image, ImageOps, UnidentifiedImageError + from spoolman import env DEFAULT_SOURCE_REPO = "MarksMakerSpace/filament-profiles" @@ -84,6 +89,134 @@ def resolve_vendor_logo_asset(path: str) -> Path | None: return None +def slugify_vendor_name(name: str | None) -> str: + """Create a filesystem-safe slug from vendor name.""" + if not name: + return "" + normalized = name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + return normalized.strip("-") + + +def _read_manifest(path: Path) -> dict[str, object] | None: + if not path.is_file(): + return None + try: + with path.open(encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _read_bundled_manifest() -> dict[str, object] | None: + return _read_manifest(get_bundled_vendor_logo_dir() / "manifest.json") + + +def _normalize_logo_asset_path(logo_url: str) -> str: + value = logo_url.strip() + if value == "": + return "" + + parsed = urlparse(value) + path = parsed.path if parsed.scheme in {"http", "https"} else value + base_path = env.get_base_path().rstrip("/") + if base_path and path.startswith(base_path + "/"): + path = path[len(base_path) + 1 :] + + normalized = path.lstrip("/") + if normalized.startswith("vendor-logos/"): + normalized = normalized[len("vendor-logos/") :] + return normalized + + +def _load_logo_source_bytes(logo_url: str) -> bytes: + value = logo_url.strip() + if value == "": + raise ValueError("Logo URL is required.") + + if value.startswith(("http://", "https://")): + request = Request(value, headers={"User-Agent": "spoolman-vendor-logo-convert"}) + with urlopen(request, timeout=60) as response: # noqa: S310 + return response.read() + + local_asset = _normalize_logo_asset_path(value) + if local_asset == "": + raise ValueError("Logo URL is required.") + + resolved = resolve_vendor_logo_asset(local_asset) + if resolved is None: + raise RuntimeError("Logo asset could not be resolved from local vendor logo paths.") + return resolved.read_bytes() + + +def _update_runtime_manifest_with_generated_print_logo(print_logo_url: str) -> None: + runtime_dir = get_runtime_vendor_logo_dir() + runtime_manifest_path = runtime_dir / "manifest.json" + + manifest = _read_local_manifest() or _read_bundled_manifest() or {} + + web_files = [value for value in manifest.get("web_files", []) if isinstance(value, str)] + print_files = [value for value in manifest.get("print_files", []) if isinstance(value, str)] + + if print_logo_url not in print_files: + print_files.append(print_logo_url) + + now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + runtime_manifest: dict[str, object] = { + "source_repo": manifest.get("source_repo") if isinstance(manifest.get("source_repo"), str) else "local", + "source_ref": manifest.get("source_ref") if isinstance(manifest.get("source_ref"), str) else "local", + "source_url": manifest.get("source_url") if isinstance(manifest.get("source_url"), str) else "local", + "source_tree_signature": ( + manifest.get("source_tree_signature") + if isinstance(manifest.get("source_tree_signature"), str) + else hashlib.sha256("\n".join(sorted(web_files + print_files)).encode("utf-8")).hexdigest() + ), + "synced_at_utc": now_utc, + "web_logo_count": len(web_files), + "print_logo_count": len(print_files), + "web_files": sorted(set(web_files)), + "print_files": sorted(set(print_files)), + } + + with runtime_manifest_path.open("w", encoding="utf-8") as file: + json.dump(runtime_manifest, file, indent=2) + + +def convert_web_logo_to_print_logo(logo_url: str, vendor_name: str | None = None) -> str: + """Convert a web logo to grayscale PNG and store it in runtime print logo directory.""" + source_bytes = _load_logo_source_bytes(logo_url) + + try: + with Image.open(BytesIO(source_bytes)) as source_image: + rgba = source_image.convert("RGBA") + except UnidentifiedImageError as exc: + raise RuntimeError("Logo image format is not supported.") from exc + + alpha = rgba.getchannel("A") + grayscale = ImageOps.grayscale(rgba.convert("RGB")) + monochrome = grayscale.point(lambda value: 0 if value < 180 else 255, mode="L") + converted = Image.merge("RGBA", (monochrome, monochrome, monochrome, alpha)) + + parsed_logo_path = urlparse(logo_url).path if logo_url.startswith(("http://", "https://")) else logo_url + source_stem = Path(parsed_logo_path).stem.replace("-web", "") + slug = slugify_vendor_name(vendor_name) or slugify_vendor_name(source_stem) or f"vendor-{uuid4().hex[:8]}" + source_hash = hashlib.sha1(source_bytes).hexdigest()[:10] + + runtime_dir = get_runtime_vendor_logo_dir() + print_dir = runtime_dir / "print" + print_dir.mkdir(parents=True, exist_ok=True) + + filename = f"{slug}-{source_hash}-print-auto.png" + output_path = print_dir / filename + converted.save(output_path, format="PNG", optimize=True) + + print_logo_url = f"/vendor-logos/print/{filename}" + _update_runtime_manifest_with_generated_print_logo(print_logo_url) + return print_logo_url + + def _read_local_manifest() -> dict[str, object] | None: manifest_path = get_runtime_vendor_logo_dir() / "manifest.json" if not manifest_path.is_file(): @@ -185,65 +318,89 @@ def _write_manifest( return manifest +def _get_archive_url(source_repo: str, source_ref: str) -> str: + encoded_ref = quote(source_ref, safe="") + return f"https://codeload.github.com/{source_repo}/tar.gz/{encoded_ref}" + + def _download_and_write_logo_pack(remote_state: VendorLogoRemoteState) -> dict[str, object]: target_dir = get_runtime_vendor_logo_dir() - web_dir = target_dir / "web" - print_dir = target_dir / "print" - web_dir.mkdir(parents=True, exist_ok=True) - print_dir.mkdir(parents=True, exist_ok=True) + staged_target_dir = Path(tempfile.mkdtemp(prefix=".vendor-logos-stage-", dir=target_dir.parent)) - for path in web_dir.glob("*.png"): - path.unlink() - for path in print_dir.glob("*.png"): - path.unlink() + try: + web_dir = staged_target_dir / "web" + print_dir = staged_target_dir / "print" + web_dir.mkdir(parents=True, exist_ok=True) + print_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as temp_dir_str: + temp_dir = Path(temp_dir_str) + archive_path = temp_dir / "logos.tar.gz" + archive_url = _get_archive_url(remote_state.source_repo, remote_state.source_ref) + request = Request(archive_url, headers={"User-Agent": "spoolman-vendor-logo-sync"}) + with urlopen(request, timeout=60) as response: # noqa: S310 + archive_path.write_bytes(response.read()) + + with tarfile.open(archive_path, mode="r:gz") as archive: + archive.extractall(path=temp_dir) # noqa: S202 + + source_logo_dir = _find_first_subdir(temp_dir, "logos") + source_web_dir = _find_first_subdir(temp_dir, "web") + if source_logo_dir is None and source_web_dir is None: + raise RuntimeError("Downloaded archive did not contain logos/ or web/ directories.") + + web_files: list[str] = [] + print_files: list[str] = [] + + if source_web_dir is not None: + for source_file in sorted(source_web_dir.rglob("*.png")): + dest_file = web_dir / source_file.name + shutil.copy2(source_file, dest_file) + web_files.append(f"/vendor-logos/web/{source_file.name}") + + if source_logo_dir is not None: + for source_file in sorted(source_logo_dir.rglob("*.png")): + filename = source_file.name + if filename.lower().endswith("-web.png"): + if source_web_dir is None: + dest_file = web_dir / filename + shutil.copy2(source_file, dest_file) + web_files.append(f"/vendor-logos/web/{filename}") + continue + dest_file = print_dir / filename + shutil.copy2(source_file, dest_file) + print_files.append(f"/vendor-logos/print/{filename}") + + manifest = _write_manifest( + target_dir=staged_target_dir, + source_repo=remote_state.source_repo, + source_ref=remote_state.source_ref, + source_url=remote_state.source_url, + signature=remote_state.signature, + web_files=web_files, + print_files=print_files, + ) - with tempfile.TemporaryDirectory() as temp_dir_str: - temp_dir = Path(temp_dir_str) - archive_path = temp_dir / "logos.tar.gz" - archive_url = f"https://codeload.github.com/{remote_state.source_repo}/tar.gz/refs/heads/{remote_state.source_ref}" - request = Request(archive_url, headers={"User-Agent": "spoolman-vendor-logo-sync"}) - with urlopen(request, timeout=60) as response: # noqa: S310 - archive_path.write_bytes(response.read()) - - with tarfile.open(archive_path, mode="r:gz") as archive: - archive.extractall(path=temp_dir) # noqa: S202 - - source_logo_dir = _find_first_subdir(temp_dir, "logos") - source_web_dir = _find_first_subdir(temp_dir, "web") - if source_logo_dir is None and source_web_dir is None: - raise RuntimeError("Downloaded archive did not contain logos/ or web/ directories.") - - web_files: list[str] = [] - print_files: list[str] = [] - - if source_web_dir is not None: - for source_file in sorted(source_web_dir.rglob("*.png")): - dest_file = web_dir / source_file.name - shutil.copy2(source_file, dest_file) - web_files.append(f"/vendor-logos/web/{source_file.name}") - - if source_logo_dir is not None: - for source_file in sorted(source_logo_dir.rglob("*.png")): - filename = source_file.name - if filename.lower().endswith("-web.png"): - if source_web_dir is None: - dest_file = web_dir / filename - shutil.copy2(source_file, dest_file) - web_files.append(f"/vendor-logos/web/{filename}") - continue - dest_file = print_dir / filename - shutil.copy2(source_file, dest_file) - print_files.append(f"/vendor-logos/print/{filename}") - - return _write_manifest( - target_dir=target_dir, - source_repo=remote_state.source_repo, - source_ref=remote_state.source_ref, - source_url=remote_state.source_url, - signature=remote_state.signature, - web_files=web_files, - print_files=print_files, - ) + backup_dir = target_dir.parent / f".vendor-logos-backup-{uuid4().hex}" + had_existing_target = target_dir.exists() + try: + if had_existing_target: + os.replace(target_dir, backup_dir) + os.replace(staged_target_dir, target_dir) + except Exception: + if staged_target_dir.exists(): + shutil.rmtree(staged_target_dir, ignore_errors=True) + if had_existing_target and backup_dir.exists() and not target_dir.exists(): + os.replace(backup_dir, target_dir) + raise + else: + if backup_dir.exists(): + shutil.rmtree(backup_dir, ignore_errors=True) + + return manifest + finally: + if staged_target_dir.exists(): + shutil.rmtree(staged_target_dir, ignore_errors=True) def sync_logo_pack_from_github_if_needed() -> VendorLogoSyncResult: diff --git a/uv.lock b/uv.lock index 27086563c..52955e348 100644 --- a/uv.lock +++ b/uv.lock @@ -704,6 +704,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -1116,6 +1218,7 @@ dependencies = [ { name = "hishel" }, { name = "httptools", marker = "platform_machine != 'armv7l'" }, { name = "httpx" }, + { name = "pillow" }, { name = "platformdirs" }, { name = "prometheus-client" }, { name = "psycopg2-binary" }, @@ -1147,6 +1250,7 @@ requires-dist = [ { name = "hishel", specifier = "~=0.1" }, { name = "httptools", marker = "platform_machine != 'armv7l'", specifier = ">=0.6.4" }, { name = "httpx", specifier = "~=0.28" }, + { name = "pillow", specifier = "~=11.0" }, { name = "platformdirs", specifier = "~=4.3" }, { name = "prometheus-client", specifier = "~=0.21" }, { name = "psycopg2-binary", specifier = "~=2.9" },