diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 000000000..800dcc9b2 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,31 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - hannu + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Log in to Container Registry + run: | + REGISTRY="${GITHUB_SERVER_URL#https://}" + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ github.repository_owner }}" --password-stdin + + - name: Build and push Docker image + run: | + REGISTRY="${GITHUB_SERVER_URL#https://}" + IMAGE="$REGISTRY/${GITHUB_REPOSITORY,,}" + docker buildx build --push \ + -t "$IMAGE:${{ github.sha }}" \ + -t "$IMAGE:latest" \ + . + + - name: Log out from Registry + run: docker logout "${GITHUB_SERVER_URL#https://}" diff --git a/Dockerfile b/Dockerfile index 94d1612c6..68711d422 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +FROM node:20-slim AS node-builder + +WORKDIR /build +COPY client/package.json client/package-lock.json client/.npmrc ./ +RUN npm ci +COPY client/ ./ +RUN echo "VITE_APIURL=/api/v1" > .env.production && npm run build + FROM python:3.14-slim-bookworm AS python-builder ENV UV_COMPILE_BYTECODE=1 @@ -51,7 +59,7 @@ RUN groupmod -g 1000 users \ && chown -R app:app /home/app/.local/share/spoolman # Copy built client -COPY --chown=app:app ./client/dist /home/app/spoolman/client/dist +COPY --from=node-builder --chown=app:app /build/dist /home/app/spoolman/client/dist # Copy built app COPY --chown=app:app --from=python-builder /home/app/spoolman /home/app/spoolman diff --git a/README.md b/README.md index a6a796cf8..2abdf3813 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +> **This is a personal fork of [Donkie/Spoolman](https://github.com/Donkie/Spoolman)** by [Hannu Teulahti](https://github.com/hannteu). +> +> **Why this fork?** I wanted human-readable color names on filament spools — being able to say "Neon Green" instead of remembering `#39FF14`. The feature covers the full stack: a nearest-neighbor color lookup table, API input/output, filtering, sorting, and a color autocomplete with live swatches in the UI. +> +> I also changed the Docker build to compile the frontend from source instead of copying it from the upstream image, which makes it possible to ship frontend changes in a fork without depending on the upstream release cycle. +> +> **Note on AI assistance:** All ideas and requirements are my own. Implementation was done with extensive assistance from [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant). The architecture decisions, feature design, and direction were driven by me — Claude handled a large part of the code generation, iteration, and debugging. +> +> The commits on the `feature/color-name` branch are cleaned-up, logically grouped versions of the development history, intended to make it easy for the upstream author to review and cherry-pick anything useful. + diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..41c7c6e2b 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -167,6 +167,7 @@ "spool_weight": "Empty Weight", "location": "Location", "lot_nr": "Lot Nr", + "filament_color_name": "Color", "first_used": "First Used", "last_used": "Last Used", "registered": "Registered", @@ -232,6 +233,7 @@ "settings_extruder_temp": "Extruder Temp", "settings_bed_temp": "Bed Temp", "color_hex": "Color", + "color_name": "Color", "single_color": "Single", "multi_color": "Multi", "coaxial": "Coextruded", @@ -246,7 +248,8 @@ "weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.", "spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.", "article_number": "E.g. EAN, UPC, etc.", - "multi_color_direction": "Filaments can have multiple colors in two ways: either through coextrusion, like dual-color filaments with consistent multi-colors, or through longitudinal color changes, like gradient filaments that shift colors along the spool." + "multi_color_direction": "Filaments can have multiple colors in two ways: either through coextrusion, like dual-color filaments with consistent multi-colors, or through longitudinal color changes, like gradient filaments that shift colors along the spool.", + "color_name": "Type a color name (e.g. \"Neon Green\") to set the color by name instead of the color picker." }, "titles": { "create": "Create Filament", diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..1142b0ecd 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -173,6 +173,13 @@ export function SortedColumn(props: BaseColumnProps) { }); } +export function TextColumn(props: BaseColumnProps) { + return Column({ + ...props, + sorter: false, + }); +} + export function RichColumn( props: Omit, "transform"> & { transform?: (value: unknown) => string }, ) { @@ -340,7 +347,6 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< onCell: () => { return { style: { - paddingLeft: 0, paddingTop: 0, paddingBottom: 0, }, diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 0adabd80c..f7a1aeea8 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -201,3 +201,55 @@ export function useSpoolmanLocations(enabled: boolean = false) { }, }); } + +export function useSpoolmanColorNames(enabled: boolean = false) { + return useQuery({ + enabled: enabled, + queryKey: ["colorNames"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/filament/color-names"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.sort(); + }, + }); +} + +export function useSpoolmanUsedColorNames(enabled: boolean = false) { + return useQuery<{ name: string; hex: string }[], unknown, ColumnFilterItem[]>({ + enabled: enabled, + queryKey: ["usedColorNames"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/filament/used-colors"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.map(({ name, hex }) => ({ + text: ( + + + {name} + + ), + value: '"' + name + '"', + })); + }, + }); +} diff --git a/client/src/pages/filaments/create.tsx b/client/src/pages/filaments/create.tsx index e50f4ba92..ed9438341 100644 --- a/client/src/pages/filaments/create.tsx +++ b/client/src/pages/filaments/create.tsx @@ -1,6 +1,6 @@ import { Create, useForm, useSelect } from "@refinedev/antd"; import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core"; -import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; +import { AutoComplete, Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -12,6 +12,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ". import { ExternalFilament } from "../../utils/queryExternalDB"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { getCurrencySymbol, useCurrency } from "../../utils/settings"; +import { getAPIURL } from "../../utils/url"; import { getOrCreateVendorFromExternal } from "../vendors/functions"; import { IVendor } from "../vendors/model"; import { IFilament, IFilamentParsedExtras } from "./model"; @@ -33,6 +34,14 @@ export const FilamentCreate = (props: IResourceComponentsProps & CreateOrClonePr const [isImportExtOpen, setIsImportExtOpen] = useState(false); const invalidate = useInvalidate(); const [colorType, setColorType] = useState<"single" | "multi">("single"); + const [colorMap, setColorMap] = useState>({}); + + useEffect(() => { + fetch(getAPIURL() + "/filament/color-map") + .then((r) => r.json()) + .then(setColorMap) + .catch(() => undefined); + }, []); const { form, formProps, formLoading, onFinish, redirect } = useForm< IFilament, @@ -192,7 +201,52 @@ export const FilamentCreate = (props: IResourceComponentsProps & CreateOrClonePr return e?.toHex(); }} > - + { + const hex = color.toHex(); + if (!hex || Object.keys(colorMap).length === 0) return; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + let best = ""; + let bestDist = Infinity; + for (const [name, h] of Object.entries(colorMap)) { + const dr = r - parseInt(h.slice(0, 2), 16); + const dg = g - parseInt(h.slice(2, 4), 16); + const db = b - parseInt(h.slice(4, 6), 16); + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { bestDist = dist; best = name; } + } + form.setFieldValue("color_name", best); + }} + /> + + )} + {colorType == "single" && ( + + ({ + value: name, + label: ( +
+
+ {name} +
+ ), + }))} + filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())} + onSelect={(name: string) => { + const hex = colorMap[name]; + if (hex) form.setFieldValue("color_hex", hex); + }} + allowClear + /> )} {colorType == "multi" && ( diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx index bea70e63d..facee23eb 100644 --- a/client/src/pages/filaments/edit.tsx +++ b/client/src/pages/filaments/edit.tsx @@ -1,6 +1,6 @@ import { Edit, useForm, useSelect } from "@refinedev/antd"; import { HttpError, useTranslate } from "@refinedev/core"; -import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd"; +import { Alert, AutoComplete, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; import { useEffect, useState } from "react"; @@ -9,6 +9,7 @@ import { MultiColorPicker } from "../../components/multiColorPicker"; import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { getCurrencySymbol, useCurrency } from "../../utils/settings"; +import { getAPIURL } from "../../utils/url"; import { IVendor } from "../vendors/model"; import { IFilament, IFilamentParsedExtras } from "./model"; @@ -26,8 +27,16 @@ export const FilamentEdit = () => { const extraFields = useGetFields(EntityType.filament); const currency = useCurrency(); const [colorType, setColorType] = useState<"single" | "multi">("single"); + const [colorMap, setColorMap] = useState>({}); - const { formProps, saveButtonProps } = useForm({ + useEffect(() => { + fetch(getAPIURL() + "/filament/color-map") + .then((r) => r.json()) + .then(setColorMap) + .catch(() => undefined); + }, []); + + const { form, formProps, saveButtonProps } = useForm({ liveMode: "manual", onLiveEvent() { // Warn the user if the filament has been updated since the form was opened @@ -170,7 +179,51 @@ export const FilamentEdit = () => { return e?.toHex(); }} > - + { + const hex = color.toHex(); + if (!hex || Object.keys(colorMap).length === 0) return; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + let best = ""; + let bestDist = Infinity; + for (const [name, h] of Object.entries(colorMap)) { + const dr = r - parseInt(h.slice(0, 2), 16); + const dg = g - parseInt(h.slice(2, 4), 16); + const db = b - parseInt(h.slice(4, 6), 16); + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { bestDist = dist; best = name; } + } + form.setFieldValue("color_name", best); + }} + /> + + )} + {colorType == "single" && ( + + ({ + value: name, + label: ( +
+
+ {name} +
+ ), + }))} + filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())} + onSelect={(name: string) => { + const hex = colorMap[name]; + if (hex) form.setFieldValue("color_hex", hex); + }} + allowClear + /> )} {colorType == "multi" && ( diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..4741f2bda 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -15,12 +15,14 @@ import { RichColumn, SortedColumn, SpoolIconColumn, + TextColumn, } from "../../components/column"; import { useLiveify } from "../../components/liveify"; import { useSpoolmanArticleNumbers, useSpoolmanFilamentNames, useSpoolmanMaterials, + useSpoolmanUsedColorNames, useSpoolmanVendors, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; @@ -56,6 +58,7 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [ "id", "vendor.name", "name", + "color_name", "material", "price", "density", @@ -240,15 +243,36 @@ export const FilamentList = () => { ...commonProps, id: "name", i18ncat: "filament", - color: (record: IFilamentCollapsed) => - record.multi_color_hexes - ? { - colors: record.multi_color_hexes.split(","), - vertical: record.multi_color_direction === "longitudinal", - } - : record.color_hex, + color: () => undefined, filterValueQuery: useSpoolmanFilamentNames(), }), + FilteredQueryColumn({ + ...commonProps, + id: "color_name", + i18ncat: "filament", + width: 160, + sorter: true, + filterValueQuery: useSpoolmanUsedColorNames(), + render: (value: string | undefined, record: IFilamentCollapsed) => + value ? ( + + {record.color_hex && ( + + )} + {value} + + ) : null, + }), FilteredQueryColumn({ ...commonProps, id: "material", diff --git a/client/src/pages/filaments/model.tsx b/client/src/pages/filaments/model.tsx index b21c709ea..187126b7a 100644 --- a/client/src/pages/filaments/model.tsx +++ b/client/src/pages/filaments/model.tsx @@ -16,6 +16,7 @@ export interface IFilament { settings_extruder_temp?: number; settings_bed_temp?: number; color_hex?: string; + color_name?: string; multi_color_hexes?: string; multi_color_direction?: string; external_id?: string; diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx index 4bc6c66be..49bce0274 100644 --- a/client/src/pages/filaments/show.tsx +++ b/client/src/pages/filaments/show.tsx @@ -89,6 +89,7 @@ export const FilamentShow = () => { {t("filament.fields.color_hex")} {colorObj && } {record?.color_hex && } + {record?.color_name && } {t("filament.fields.material")} {t("filament.fields.price")} diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 9ee11a3ed..9a19cd7d6 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -32,6 +32,7 @@ import { useSpoolmanLocations, useSpoolmanLotNumbers, useSpoolmanMaterials, + useSpoolmanUsedColorNames, } from "../../components/otherModels"; import { removeUndefined } from "../../utils/filtering"; import { EntityType, useGetFields } from "../../utils/queryFields"; @@ -48,6 +49,7 @@ interface ISpoolCollapsed extends ISpool { "filament.combined_name": string; // Eg. "Prusa - PLA Red" "filament.id": number; "filament.material"?: string; + "filament.color_name"?: string; } function collapseSpool(element: ISpool): ISpoolCollapsed { @@ -65,6 +67,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed { "filament.combined_name": filament_name, "filament.id": element.filament.id, "filament.material": element.filament.material, + "filament.color_name": element.filament.color_name, }; } @@ -80,6 +83,7 @@ const namespace = "spoolList-v2"; const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", "filament.combined_name", + "filament.color_name", "filament.material", "price", "used_weight", @@ -357,16 +361,36 @@ export const SpoolList = () => { ...commonProps, id: "filament.combined_name", i18nkey: "spool.fields.filament_name", - color: (record: ISpoolCollapsed) => - record.filament.multi_color_hexes - ? { - colors: record.filament.multi_color_hexes.split(","), - vertical: record.filament.multi_color_direction === "longitudinal", - } - : record.filament.color_hex, + color: () => undefined, dataId: "filament.combined_name", filterValueQuery: useSpoolmanFilamentFilter(), }), + FilteredQueryColumn({ + ...commonProps, + id: "filament.color_name", + i18nkey: "filament.fields.color_name", + width: 160, + filterValueQuery: useSpoolmanUsedColorNames(), + render: (value: string | undefined, record: ISpoolCollapsed) => + value ? ( + + {record.filament.color_hex && ( + + )} + {value} + + ) : null, + }), FilteredQueryColumn({ ...commonProps, id: "filament.material", diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..868be0959 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Filament, FilamentEvent, Message, MultiColorDirection +from spoolman.color_names import color_map as _color_map, color_name_to_hex, hex_to_color_name, list_color_names as _list_color_names from spoolman.database import filament from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder @@ -92,6 +93,16 @@ class FilamentParameters(BaseModel): ), examples=["FF0000"], ) + color_name: str | None = Field( + None, + max_length=64, + description=( + "Human-readable color name, e.g. 'Lime Green'. " + "When provided, sets color_hex to the matching hex value. " + "Use GET /filament/color-names for the list of accepted names." + ), + examples=["Lime Green"], + ) multi_color_hexes: str | None = Field( None, description=( @@ -119,6 +130,21 @@ class FilamentParameters(BaseModel): description="Extra fields for this filament.", ) + @model_validator(mode="before") # type: ignore[] + @classmethod + def resolve_color_name(cls, values: dict) -> dict: + """If color_name is provided, resolve it to color_hex.""" + name = values.get("color_name") + if name: + hex_value = color_name_to_hex(name) + if hex_value is None: + raise ValueError( + f"Unknown color name '{name}'. Use GET /filament/color-names for valid names." + ) + if not values.get("color_hex"): + values["color_hex"] = hex_value + return values + @field_validator("color_hex") @classmethod def color_hex_validator(cls, v: str | None) -> str | None: @@ -275,6 +301,16 @@ async def find( ), ), ] = None, + color_name: Annotated[ + str | None, + Query( + title="Filament Color Name", + description=( + "Filter by human-readable color name. Separate multiple names with a comma. " + "Use GET /filament/color-names for valid names." + ), + ), + ] = None, color_hex: Annotated[ str | None, Query( @@ -351,6 +387,7 @@ async def find( material=material, article_number=article_number, external_id=external_id, + color_name=color_name, sort_by=sort_by, limit=limit, offset=offset, @@ -384,6 +421,43 @@ async def notify_any( websocket_manager.disconnect(("filament",), websocket) +@router.get( + "/color-names", + name="List color names", + description="Return all accepted human-readable color names that can be passed as color_name in create/update requests.", + response_model_exclude_none=True, +) +async def get_color_names() -> list[str]: + return _list_color_names() + + +@router.get( + "/used-colors", + name="List used colors", + description="Return the distinct color names and hex codes of all filaments currently in the database.", + response_model_exclude_none=True, +) +async def get_used_colors(db: Annotated[AsyncSession, Depends(get_db_session)]) -> list[dict[str, str]]: + db_items, _ = await filament.find(db=db) + seen: dict[str, str] = {} + for f in db_items: + if f.color_hex: + name = hex_to_color_name(f.color_hex) + if name and name not in seen: + seen[name] = f.color_hex + return [{"name": name, "hex": hex_val} for name, hex_val in sorted(seen.items())] + + +@router.get( + "/color-map", + name="Get color map", + description="Return a mapping of all accepted color names to their hex codes.", + response_model_exclude_none=True, +) +async def get_color_map() -> dict[str, str]: + return _color_map() + + @router.get( "/{filament_id}", name="Get filament", diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index a24d79f7c..222449719 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, PlainSerializer +from spoolman.color_names import hex_to_color_name from spoolman.database import models from spoolman.math import length_from_weight from spoolman.settings import SettingDefinition, SettingType @@ -168,6 +169,14 @@ class Filament(BaseModel): ), examples=["FF0000"], ) + color_name: str | None = Field( + None, + description=( + "Human-readable name of the nearest matching color, e.g. 'Red', 'Lime Green'. " + "Derived from color_hex using nearest-neighbor lookup. Read-only." + ), + examples=["Red"], + ) multi_color_hexes: str | None = Field( None, min_length=6, @@ -217,6 +226,7 @@ def from_db(item: models.Filament) -> "Filament": settings_extruder_temp=item.settings_extruder_temp, settings_bed_temp=item.settings_bed_temp, color_hex=item.color_hex, + color_name=hex_to_color_name(item.color_hex), multi_color_hexes=item.multi_color_hexes, multi_color_direction=( MultiColorDirection(item.multi_color_direction) if item.multi_color_direction is not None else None diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..2244ec055 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -199,6 +199,17 @@ async def find( ), ), ] = None, + filament_color_name: Annotated[ + str | None, + Query( + alias="filament.color_name", + title="Filament Color Name", + description=( + "Filter by human-readable color name of the filament. Separate multiple names with a comma. " + "Use GET /filament/color-names for valid names." + ), + ), + ] = None, filament_vendor_name: Annotated[ str | None, Query( @@ -290,6 +301,7 @@ async def find( filament_name=filament_name if filament_name is not None else filament_name_old, filament_id=filament_ids, filament_material=filament_material if filament_material is not None else filament_material_old, + filament_color_name=filament_color_name, vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, vendor_id=filament_vendor_ids, location=location, diff --git a/spoolman/color_names.py b/spoolman/color_names.py new file mode 100644 index 000000000..7d2699120 --- /dev/null +++ b/spoolman/color_names.py @@ -0,0 +1,138 @@ +"""Nearest-neighbor color name lookup using CSS named colors + common filament shades.""" + +from __future__ import annotations + +import math + +# (name, (R, G, B)) +_COLOR_TABLE: list[tuple[str, tuple[int, int, int]]] = [ + # Achromatic + ("Black", (0, 0, 0)), + ("White", (255, 255, 255)), + ("Light Gray", (211, 211, 211)), + ("Gray", (128, 128, 128)), + ("Dark Gray", (64, 64, 64)), + ("Silver", (192, 192, 192)), + # Reds + ("Red", (255, 0, 0)), + ("Dark Red", (139, 0, 0)), + ("Crimson", (220, 20, 60)), + ("Firebrick", (178, 34, 34)), + # Pinks + ("Pink", (255, 192, 203)), + ("Hot Pink", (255, 105, 180)), + ("Deep Pink", (255, 20, 147)), + ("Light Pink", (255, 182, 193)), + # Oranges + ("Orange", (255, 165, 0)), + ("Dark Orange", (255, 140, 0)), + ("Coral", (255, 127, 80)), + ("Tomato", (255, 99, 71)), + ("Orange Red", (255, 69, 0)), + # Yellows + ("Yellow", (255, 255, 0)), + ("Gold", (255, 215, 0)), + ("Amber", (255, 191, 0)), + ("Dark Yellow", (204, 204, 0)), + ("Khaki", (240, 230, 140)), + ("Light Yellow", (255, 255, 224)), + ("Lemon", (255, 247, 0)), + # Greens + ("Lime", (0, 255, 0)), + ("Lime Green", (50, 205, 50)), + ("Neon Green", (57, 255, 20)), + ("Green", (0, 128, 0)), + ("Dark Green", (0, 100, 0)), + ("Forest Green", (34, 139, 34)), + ("Olive", (128, 128, 0)), + ("Olive Green", (107, 142, 35)), + ("Yellow Green", (154, 205, 50)), + ("Spring Green", (0, 255, 127)), + ("Mint", (62, 180, 137)), + ("Teal", (0, 128, 128)), + # Cyans + ("Cyan", (0, 255, 255)), + ("Aqua", (0, 255, 255)), + ("Dark Cyan", (0, 139, 139)), + ("Turquoise", (64, 224, 208)), + ("Sky Blue", (135, 206, 235)), + ("Steel Blue", (70, 130, 180)), + # Blues + ("Blue", (0, 0, 255)), + ("Dark Blue", (0, 0, 139)), + ("Navy", (0, 0, 128)), + ("Royal Blue", (65, 105, 225)), + ("Cornflower Blue", (100, 149, 237)), + ("Dodger Blue", (30, 144, 255)), + ("Deep Sky Blue", (0, 191, 255)), + ("Light Blue", (173, 216, 230)), + ("Powder Blue", (176, 224, 230)), + # Purples / Violets + ("Purple", (128, 0, 128)), + ("Dark Purple", (75, 0, 130)), + ("Violet", (238, 130, 238)), + ("Magenta", (255, 0, 255)), + ("Fuchsia", (255, 0, 255)), + ("Orchid", (218, 112, 214)), + ("Medium Purple", (147, 112, 219)), + ("Indigo", (75, 0, 130)), + ("Lavender", (230, 230, 250)), + ("Plum", (221, 160, 221)), + # Browns + ("Brown", (165, 42, 42)), + ("Saddle Brown", (139, 69, 19)), + ("Sienna", (160, 82, 45)), + ("Chocolate", (210, 105, 30)), + ("Peru", (205, 133, 63)), + ("Tan", (210, 180, 140)), + ("Beige", (245, 245, 220)), + ("Wheat", (245, 222, 179)), + ("Burlywood", (222, 184, 135)), +] + + +def _rgb_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float: + return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) + + +def list_color_names() -> list[str]: + """Return all available color names, sorted alphabetically.""" + return sorted(name for name, _ in _COLOR_TABLE) + + +def color_map() -> dict[str, str]: + """Return a dict mapping each color name to its hex code, sorted alphabetically by name.""" + return {name: f"{r:02X}{g:02X}{b:02X}" for name, (r, g, b) in sorted(_COLOR_TABLE, key=lambda x: x[0])} + + +def color_name_to_hex(name: str) -> str | None: + """Return the hex code for a named color (case-insensitive). Returns None if not found.""" + needle = name.strip().lower() + for color_name, (r, g, b) in _COLOR_TABLE: + if color_name.lower() == needle: + return f"{r:02X}{g:02X}{b:02X}" + return None + + +def hex_to_color_name(hex_code: str | None) -> str | None: + """Return the nearest human-readable color name for a hex color code. + + Returns None if hex_code is None or unparseable. + """ + if not hex_code: + return None + try: + h = hex_code.lstrip("#")[:6].upper() + if len(h) < 6: + return None + rgb: tuple[int, int, int] = (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) + except (ValueError, IndexError): + return None + + best_name, best_dist = _COLOR_TABLE[0][0], float("inf") + for name, ref in _COLOR_TABLE: + d = _rgb_distance(rgb, ref) + if d < best_dist: + best_dist = d + best_name = name + return best_name diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..3273373e0 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -20,6 +20,7 @@ add_where_clause_str_opt, parse_nested_field, ) +from spoolman.color_names import hex_to_color_name from spoolman.exceptions import ItemDeleteError, ItemNotFoundError from spoolman.math import delta_e, hex_to_rgb, rgb_to_lab from spoolman.ws import websocket_manager @@ -102,6 +103,7 @@ async def find( material: str | None = None, article_number: str | None = None, external_id: str | None = None, + color_name: str | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -129,11 +131,17 @@ async def find( total_count = None + # color_name is computed, not a DB column — handle sort and filter separately + color_name_sort: SortOrder | None = None + if sort_by is not None and "color_name" in sort_by: + color_name_sort = sort_by.pop("color_name") + 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 color_name_sort is None: + stmt = stmt.offset(offset).limit(limit) if sort_by is not None: for fieldstr, order in sort_by.items(): @@ -148,9 +156,22 @@ async def find( execution_options={"populate_existing": True}, ) result = list(rows.unique().scalars().all()) + + if color_name is not None: + filter_names = {n.strip().strip('"').lower() for n in color_name.split(",")} + result = [f for f in result if (hex_to_color_name(f.color_hex) or "").lower() in filter_names] + total_count = len(result) + if total_count is None: total_count = len(result) + if color_name_sort is not None: + result.sort( + key=lambda f: hex_to_color_name(f.color_hex) or "", + reverse=(color_name_sort == SortOrder.DESC), + ) + result = result[offset : offset + limit] if limit is not None else result + return result, total_count diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..e7851999b 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -12,6 +12,7 @@ from sqlalchemy.sql.functions import coalesce from spoolman.api.v1.models import EventType, Spool, SpoolEvent +from spoolman.color_names import hex_to_color_name from spoolman.database import filament, models from spoolman.database.utils import ( SortOrder, @@ -117,6 +118,7 @@ async def find( # noqa: C901, PLR0912 filament_name: str | None = None, filament_id: int | Sequence[int] | None = None, filament_material: str | None = None, + filament_color_name: str | None = None, vendor_name: str | None = None, vendor_id: int | Sequence[int] | None = None, location: str | None = None, @@ -159,11 +161,17 @@ async def find( # noqa: C901, PLR0912 total_count = None + # filament.color_name is computed, not a DB column — handle sort and filter separately + color_name_sort: SortOrder | None = None + if sort_by is not None and "filament.color_name" in sort_by: + color_name_sort = sort_by.pop("filament.color_name") + 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 color_name_sort is None and filament_color_name is None: + stmt = stmt.offset(offset).limit(limit) if sort_by is not None: for fieldstr, order in sort_by.items(): @@ -202,9 +210,24 @@ async def find( # noqa: C901, PLR0912 execution_options={"populate_existing": True}, ) result = list(rows.unique().scalars().all()) + + if filament_color_name is not None: + filter_names = {n.strip().strip('"').lower() for n in filament_color_name.split(",")} + result = [s for s in result if (hex_to_color_name(s.filament.color_hex) or "").lower() in filter_names] + total_count = len(result) + if total_count is None: total_count = len(result) + if color_name_sort is not None: + result.sort( + key=lambda s: hex_to_color_name(s.filament.color_hex) or "", + reverse=(color_name_sort == SortOrder.DESC), + ) + result = result[offset : offset + limit] if limit is not None else result + elif filament_color_name is not None and limit is not None: + result = result[offset : offset + limit] + return result, total_count