Merge remote-tracking branch 'origin/master' into worktree-client-v2-401-reload

# Conflicts:
#	client_v2/src/lib/api/search.ts
This commit is contained in:
Donkie committed 2026-08-02 14:13:41 +02:00
commit ae12072cdd
18 files changed
+1106 -45

No files matched your search

+1
View File
@@ -356,6 +356,7 @@
"boolean_true": "Yes",
"boolean_false": "No",
"choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}",
"choices_reorder_hint": "Drag, or use the arrow keys, to reorder this choice.",
"non_unique_key_error": "The key must be unique.",
"key_not_changed": "Please change the key to something else.",
"delete_confirm": "Delete field {{name}}?",
+121 -15
View File
@@ -1,4 +1,4 @@
import { PlusOutlined } from "@ant-design/icons";
import { HolderOutlined, PlusOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import {
Button,
@@ -12,6 +12,7 @@ import {
Select,
Space,
Table,
Tag,
message,
} from "antd";
import { FormItemProps, Rule } from "antd/es/form";
@@ -20,7 +21,9 @@ import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useState } from "react";
import { useRef, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { Trans } from "react-i18next";
import { useParams } from "react-router";
import { DateTimePicker } from "../../components/dateTimePicker";
@@ -55,6 +58,92 @@ const canEditField = (dataIndex: string, isNew: boolean) => {
return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice";
};
const CHOICE_DRAG_TYPE = "extraFieldChoice";
interface ChoiceDragItem {
choice: string;
}
interface DraggableChoiceTagProps {
label: React.ReactNode;
value: string;
closable: boolean;
onClose: (e?: React.MouseEvent<HTMLElement>) => void;
form: FormInstance;
}
/**
* A tag in the choices editor that can be dragged, or arrow-keyed, to a new position.
* The order of the choices is the order they're presented in wherever the field is used,
* so it's the choices form value itself that gets reordered.
*/
const DraggableChoiceTag = ({ label, value, closable, onClose, form }: DraggableChoiceTagProps) => {
const t = useTranslate();
const ref = useRef<HTMLSpanElement>(null);
// Choices are unique, so a choice's own value identifies its position.
const moveChoice = (choice: string, before: string) => {
const choices: string[] = [...(form.getFieldValue("choices") || [])];
const from = choices.indexOf(choice);
const to = choices.indexOf(before);
if (from === -1 || to === -1 || from === to) {
return;
}
choices.splice(from, 1);
choices.splice(to, 0, choice);
form.setFieldValue("choices", choices);
};
const moveChoiceBy = (offset: number) => {
const choices: string[] = form.getFieldValue("choices") || [];
const to = choices.indexOf(value) + offset;
if (to < 0 || to >= choices.length) {
return;
}
moveChoice(value, choices[to]);
};
const [{ isDragging }, drag] = useDrag<ChoiceDragItem, void, { isDragging: boolean }>({
type: CHOICE_DRAG_TYPE,
item: { choice: value },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
});
const [, drop] = useDrop<ChoiceDragItem>({
accept: CHOICE_DRAG_TYPE,
hover: (item) => moveChoice(item.choice, value),
});
drag(drop(ref));
return (
<span
ref={ref}
// rc-select wraps every custom tag in a span that preventDefaults mousedown,
// which stops the browser from ever starting a drag. Keep the event away from it.
onMouseDown={(e) => e.stopPropagation()}
style={{ display: "inline-flex", opacity: isDragging ? 0.4 : 1 }}
>
<Tag
closable={closable}
onClose={onClose}
tabIndex={0}
title={t("settings.extra_fields.choices_reorder_hint")}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
moveChoiceBy(e.key === "ArrowLeft" ? -1 : 1);
}
}}
style={{ marginInlineEnd: 4, cursor: "grab", userSelect: "none" }}
>
<HolderOutlined style={{ marginInlineEnd: 4 }} />
{label}
</Tag>
</span>
);
};
const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps }: EditableCellProps) => {
const t = useTranslate();
@@ -221,7 +310,22 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps
}
} else if (dataIndex === "choices") {
if (fieldType === FieldType.choice) {
inputNode = <Select mode="tags" tokenSeparators={[","]} open={false} />;
inputNode = (
<Select
mode="tags"
tokenSeparators={[","]}
open={false}
tagRender={(props) => (
<DraggableChoiceTag
label={props.label}
value={props.value}
closable={props.closable}
onClose={props.onClose}
form={form}
/>
)}
/>
);
rules.push({
required: true,
min: 1,
@@ -608,18 +712,20 @@ export function ExtraFieldsSettings() {
}}
/>
<Form form={form} component={false} disabled={isSubmitting}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
columns={mergedColumns}
dataSource={tableFields}
loading={fields.isLoading}
rowClassName="editable-row"
pagination={false}
/>
<DndProvider backend={HTML5Backend}>
<Table
components={{
body: {
cell: EditableCell,
},
}}
columns={mergedColumns}
dataSource={tableFields}
loading={fields.isLoading}
rowClassName="editable-row"
pagination={false}
/>
</DndProvider>
</Form>
{newField == null && (
<Flex justify="center">
+2
View File
@@ -364,6 +364,7 @@
"scanner.starting": "Starting camera...",
"scanner.title": "QR Code Scanner",
"search.colorHint": "Color names must be entered in English.",
"search.moreSpools": "+{{count}} more",
"search.noResults": "No matches found",
"search.searching": "Searching...",
"search.section.filaments": "Filaments",
@@ -417,6 +418,7 @@
"settings.extraFields.params.name": "Name",
"settings.extraFields.params.order": "Order",
"settings.extraFields.params.unit": "Unit",
"settings.extraFields.reorderChoice": "Drag, or use the arrow keys, to reorder this choice.",
"settings.extraFields.saveField": "Save field",
"settings.extraFields.tab": "Extra Fields",
"settings.general.baseUrl.label": "Base URL",
+233 -1
View File
@@ -1,17 +1,99 @@
{
"add.addAndNew": "Добавить и новое",
"add.addN_few": "Добавить {{count}} катушки",
"add.addN_many": "Добавить {{count}} катушек",
"add.addN_one": "Добавить {{count}} катушку",
"add.adding": "Добавление...",
"add.advanced": "Расширенные характеристики",
"add.advancedNote": "плотность, диаметр, температуры (заполняются автоматически из материала)",
"add.change": "изменить",
"add.count": "Количество",
"add.createNew": "Создать новый филамент",
"add.createNewSub": "нет в вашем каталоге или {{name}}",
"add.dbUnavailable": "{{name}} недоступен",
"add.duplicate": "клонировать",
"add.duplicateNote": "Характеристики, производитель и цена были скопированы. Задайте ему новое название и цвет.",
"add.duplicateRename": "Название все еще как у оригинала — переименуйте его, чтобы различать их.",
"add.duplicateTitle": "Новый филамент · копия «{{name}}",
"add.filamentNamePlaceholder": "например, PolyTerra Matte Sage",
"add.fill.full": "Полная",
"add.fillLevel": "Уровень заполнения",
"add.importNote": "Этот филамент будет добавлен в ваш каталог при добавлении катушек.",
"add.inCatalog": "в каталоге",
"add.locationHint": "Где физически хранится катушка — полка, коробка или принтер. Оставьте пустым, если они хранятся в одном месте.",
"add.locationPlaceholder": "например: Полка А, Сухой бокс 2, Принтер 1",
"add.manufacturerPlaceholder": "например, Polymaker",
"add.nameHint": "Укажите цвет — именно по нему можно различать филаменты.",
"add.newFilament": "новый филамент",
"add.newFilamentTitle": "Новый филамент",
"add.noCatalog": "В каталоге нет филаментов",
"add.noManufacturer": "Нет производителя",
"add.pickColor": "Выбрать цвет",
"add.searchPlaceholder": "Поиск по каталогу и {{name}}...",
"add.searching": "Поиск...",
"add.spoolWeightPreset.cardboard": "Картонная {{weight}} г",
"add.spoolWeightPreset.plastic": "Пластиковая {{weight}} г",
"add.spoolWeightPresetsLead": "Начните с типичной катушки:",
"add.step2": "Шаг 2 · детали катушки",
"add.summary_few": "{{count}} катушки",
"add.summary_many": "{{count}} катушек",
"add.summary_one": "{{count}} катушка",
"add.typeToSearchAll": "Введите для поиска по тысячам филаментов",
"add.typeToSearchMatches": "Введите для поиска — нет совпадений",
"add.useExisting": "использовать существующий",
"add.vendorHint.existing": "Используется существующий производитель «{{name}}",
"add.vendorHint.new": "Будет создан новый производитель «{{name}}",
"add.vendorHint.optional": "Необязательно — оставьте пустым, если производитель не нужен",
"add.yourCatalog": "Ваш каталог",
"buttons.archive": "В архив",
"buttons.cancel": "Отмена",
"buttons.clear": "Очистить",
"buttons.close": "Закрыть",
"buttons.delete": "Удалить",
"buttons.edit": "Редактировать",
"buttons.filter": "Фильтр",
"buttons.save": "Сохранить",
"buttons.showArchived": "Показать архивные",
"buttons.unArchive": "Из архива",
"common.decrement": "Уменьшить",
"common.increment": "Увеличить",
"common.remove": "Удалить",
"common.search": "Поиск",
"dashboard.addFieldHint": "Добавьте текстовое поле или поле выбора для катушек в Настройках, чтобы группировать и по нему",
"dashboard.dashboard": "Панель",
"dashboard.deleteGroup": "Удалить группу",
"dashboard.dragHint": "Перетащите катушку на другую карточку, чтобы изменить её {{field}}, или внутри карточки для изменения порядка",
"dashboard.dropHere": "перетащите катушки сюда",
"dashboard.emptyHelp": "На этой странице катушки группируются по выбранному вами полю. Добавьте катушки, чтобы начать!",
"dashboard.errorEmptyName": "Имя не может быть пустым",
"dashboard.errorExists": "Группа с таким именем уже существует",
"dashboard.errorRename": "Не удалось переименовать группу",
"dashboard.lastUsed": "Последнее использование {{time}} назад",
"dashboard.newGroup": "Новая группа",
"dashboard.newGroupName": "Новая группа {{n}}",
"dashboard.reorderGroup": "Перетащите для изменения порядка группы",
"dashboard.spoolCount_few": "\"{{count}} катушки",
"dashboard.spoolCount_many": "\"{{count}} катушек",
"dashboard.spoolCount_one": "\"{{count}} катушка",
"dashboard.unassigned": "Не назначено",
"datetime.done": "Готово",
"datetime.hour": "Час",
"datetime.minute": "Минута",
"datetime.nextMonth": "Следующий месяц",
"datetime.now": "Сейчас",
"datetime.pick": "Выберите дату и время",
"datetime.prevMonth": "Предыдущий месяц",
"datetime.time": "Время",
"documentTitle.dashboard.list": "Панель | Spoolman",
"documentTitle.labels.list": "Этикетки | Spoolman",
"documentTitle.library.list": "Библиотека | Spoolman",
"documentTitle.locations.list": "Местоположения | Spoolman",
"documentTitle.settings.list": "Настройки | Spoolman",
"filament.fields.addColor": "Добавить цвет",
"filament.fields.articleNumber": "Артикул",
"filament.fields.coaxial": "Коэкструдированный",
"filament.fields.colorHex": "Цвет",
"filament.fields.colorType": "Тип цвета",
"filament.fields.comment": "Комментарий",
"filament.fields.density": "Плотность",
"filament.fields.diameter": "Диаметр",
@@ -23,6 +105,7 @@
"filament.fields.name": "Имя",
"filament.fields.price": "Цена",
"filament.fields.registered": "Добавлен",
"filament.fields.removeColor": "Удалить цвет",
"filament.fields.settingsBedTemp": "Температура стола",
"filament.fields.settingsExtruderTemp": "Температура экструдера",
"filament.fields.singleColor": "Одноцветный",
@@ -34,8 +117,157 @@
"filament.fieldsHelp.spoolWeight": "Вес пустой катушки. Используется для определения измеренного веса катушки.",
"filament.fieldsHelp.weight": "Вес филамента на полной катушке (вес нетто). Сюда не следует включать вес самой катушки, а только филамента. Это то, что обычно пишут на упаковке.",
"filament.filament": "Филаменты",
"footer.documentation": "Документация",
"footer.reportIssue": "Сообщить о проблеме",
"help.help": "Помощь",
"inspector.addSpoolsOfThis": "Добавить катушки этого филамента",
"inspector.adjustFailed": "Не удалось изменить вес.",
"inspector.adjustHelp": "Положительное значение расходует филамент, отрицательное — добавляет.",
"inspector.adjustWeight": "Изменить вес",
"inspector.apply": "Применить",
"inspector.applying": "Применение...",
"inspector.colorHex": "Шестнадцатеричный код цвета",
"inspector.consumeAmount": "Количество для списания:",
"inspector.delete.deleting": "Удаление...",
"inspector.delete.errorGone": "Он уже был удален.",
"inspector.delete.errorSpoolsExist": "Невозможно удалить этот филамент — его используют катушки.",
"inspector.delete.errorUnknown": "Не удалось удалить. Подробности см. в логах сервера.",
"inspector.delete.filament": "Удалить филамент",
"inspector.delete.filamentBlockedHint": "Сначала удалите эти катушки. Архивирования недостаточно.",
"inspector.delete.filamentBlockedTitle": "Этот филамент не может быть удален",
"inspector.delete.filamentBlocked_few": "{{count}} катушки все еще использует «{{name}}», включая архивные.",
"inspector.delete.filamentBlocked_many": "{{count}} катушек все еще использует «{{name}}», включая архивные.",
"inspector.delete.filamentBlocked_one": "{{count}} катушка все еще использует «{{name}}», включая архивные.",
"inspector.delete.filamentBody": "«{{name}}» будет удален из вашего каталога. Это действие нельзя отменить.",
"inspector.delete.filamentDone": "Филамент удален.",
"inspector.delete.filamentTitle": "Удалить этот филамент?",
"inspector.delete.spool": "Удалить катушку",
"inspector.delete.spoolBody": "Катушка #{{id}} «{{name}}» будет удалена вместе с историей использования. Это действие нельзя отменить.",
"inspector.delete.spoolDone": "Катушка удалена.",
"inspector.delete.spoolRemaining": "На ней еще осталось {{weight}} филамента. Архивируйте её, если вы просто хотите убрать её с глаз.",
"inspector.delete.spoolTitle": "Удалить эту катушку?",
"inspector.delete.vendor": "Удалить производителя",
"inspector.delete.vendorBody": "«{{name}}» будет удален. Это действие нельзя отменить.",
"inspector.delete.vendorDone": "Производитель удален.",
"inspector.delete.vendorOrphans_few": "Его {{count}} филамента сохранено, но остался без производителя.",
"inspector.delete.vendorOrphans_many": "Его {{count}} филаментов сохранено, но остался без производителя.",
"inspector.delete.vendorOrphans_one": "Его {{count}} филамент сохранен, но остался без производителя.",
"inspector.delete.vendorTitle": "Удалить этого производителя?",
"inspector.duplicateFilament": "Клонировать",
"inspector.empty": "Выберите катушку, филамент или производителя из списка, чтобы просмотреть подробную информацию здесь.",
"inspector.enterValidLength": "Введите корректную длину.",
"inspector.enterValidWeight": "Введите корректный вес.",
"inspector.filamentDefault": "{{price}} · по умолчанию для филамента",
"inspector.loading": "Загрузка...",
"inspector.manageExtraFields": "Управление полями",
"inspector.measuredHelp": "Общий вес катушки + оставшийся филамент по показаниям весов.",
"inspector.multi": "мульти ×{{count}}",
"inspector.newGross": "Новый брутто-вес:",
"inspector.noExtraFields": "Пока нет — добавьте собственные поля для отслеживания того, чего нет в Spoolman.",
"inspector.notFound": "Элемент не найден. Возможно, он был удален.",
"inspector.ofRemaining": "осталось {{weight}} · {{length}} м",
"inspector.openFilament": "Открыть филамент",
"inspector.openLink": "Открыть {{url}} в новой вкладке",
"inspector.openManufacturer": "Открыть производителя",
"inspector.specs": "Характеристики — редактирование на месте",
"inspector.spoolsCount": "Катушки · {{count}}",
"inspector.spoolsTotalRemaining": "всего {{weight}}",
"inspector.used": "использовано",
"inspector.vendorSub_few": "{{count}} филамента",
"inspector.vendorSub_many": "{{count}} филаментов",
"inspector.vendorSub_one": "{{count}} филамент",
"kofi": "Поддержите меня на Ko-fi",
"labels.addHead": "Добавить",
"labels.addQr": "QR-код",
"labels.addRect": "Прямоугольник",
"labels.addSwatch": "Образец цвета",
"labels.addText": "Текст / поле",
"labels.align": "Выравнивание",
"labels.alignCenter": "По центру",
"labels.alignLeft": "По левому краю",
"labels.alignRight": "По правому краю",
"labels.blank": "У вас пока нет дизайнов этикеток.",
"labels.bold": "Жирный",
"labels.canvasHint": "Перетащите для перемещения · нажмите для выбора · маркеры изменения размера.",
"labels.centerLogo": "Центрировать логотип Spoolman",
"labels.chooseField": "Выберите поле...",
"labels.clearFill": "Без заливки (только контур)",
"labels.copies": "Копии",
"labels.cornerRadiusMm": "Радиус скругления",
"labels.createFirst": "Создайте свою первую этикетку",
"labels.cutGuides": "Линии реза",
"labels.deleteConfirm": "Удалить этот дизайн этикетки?",
"labels.deleteElement": "Удалить элемент",
"labels.designName": "Название дизайна",
"labels.designerDesc": "\"Создавайте QR-этикетки с любыми полями катушки, филамента или производителя, а затем печатайте их на листах или термопринтере. Дизайны сохраняются на сервере.",
"labels.designerTitle": "Дизайнер этикеток",
"labels.dpi": "Разрешение",
"labels.dpiHint": "Соответствуйте параметрам принтера, чтобы мелкие этикетки были четкими: 203 dpi для большинства термопринтеров, 300 или более для лазерных и струйных.",
"labels.duplicate": "Дублировать",
"labels.fields.bedTemp": "Температура стола",
"labels.fields.diameterMm": "Диаметр (мм)",
"labels.fields.groupVendor": "Производитель",
"labels.fields.initialG": "Начальный (г)",
"labels.fields.netWeightG": "Вес нетто (г)",
"labels.fields.remainingG": "Остаток (г)",
"labels.filamentsSelected_few": "{{count}} филамента выбрано",
"labels.filamentsSelected_many": "{{count}} филаментов выбрано",
"labels.filamentsSelected_one": "{{count}} филамент выбран",
"labels.fill": "Заливка",
"labels.fitWidth": "Подгонять ширину под столбцы",
"labels.fitWidthHint": "Установите ширину этикетки так, чтобы выбранное количество столбцов заполняло страницу.",
"labels.fontMm": "Шрифт",
"labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} этикеток / страница",
"labels.hMm": "В",
"labels.heightMm": "Высота",
"labels.hintNowrap": "Текст остается на одной строке; всё, что выходит за пределы ширины, обрежется.",
"labels.hintSwatch": "Заполняется цветом(-ами) филамента катушки во время печати.",
"labels.hintWrap": "Оберните поле, чтобы скрыть его, если оно пустое:",
"labels.imageDesc": "Сохраняет по одному PNG на выбранную катушку вместо печати, размер каждого {{w}}×{{h}} мм.",
"labels.imageZipNote": "Более {{threshold}} этикеток доставляются в виде одного ZIP-архива.",
"labels.insertField": "Вставить поле",
"labels.kind.qr": "QR-код",
"labels.kind.rect": "Прямоугольник",
"labels.kind.swatch": "Образец цвета",
"labels.kind.text": "Текст",
"labels.labelSize": "Размер этикетки",
"labels.labelType": "Тип этикетки",
"labels.layout": "Макет",
"labels.modeImage": "Файлы изображений",
"labels.modeLabel": "Принтер этикеток",
"labels.modeSheet": "Лист (плитка)",
"labels.new": "Новый",
"labels.newText": "Новый текст",
"labels.noDesignsOption": "Пока нет дизайнов",
"labels.noFilaments": "Нет филаментов.",
"labels.noSpools": "Нет катушек.",
"labels.onePerLabel": "Одна страница {{w}}×{{h}} мм на этикетку.",
"labels.preparing": "Подготовка...",
"labels.preview": "Предпросмотр",
"labels.previewWith": "Предпросмотр с",
"labels.printN_few": "Печать {{count}} этикетки",
"labels.printN_many": "Печать {{count}} этикеток",
"labels.printN_one": "Печать {{count}} этикетки",
"labels.radiusMm": "Радиус",
"labels.sampleData": "Пример данных",
"labels.saved": "Сохранено",
"labels.saving": "Сохранение...",
"labels.searchFilaments": "Поиск филаментов...",
"labels.searchSpools": "Поиск катушек...",
"labels.selectAllShort": "Все",
"labels.selectAtLeastOne": "Выберите хотя бы одну катушку.",
"labels.selectAtLeastOneFilament": "Выберите хотя бы один филамент.",
"labels.selectDesign": "Выберите дизайн этикетки",
"labels.selectElement": "Выберите элемент для редактирования или добавьте его из палитры.",
"labels.selectNoneShort": "Ничего",
"labels.selectRecentHint": "Выбрать катушки, добавленные за последние 24 часа",
"labels.selectRecentShort": "Недавние ({{count}})",
"labels.showingFilament": "Отображение филамента #{{id}}",
"labels.showingReal": "Отображение реальных данных катушки.",
"labels.showingSpool": "Отображение катушки #{{id}}",
"labels.sortHint": "Сортировать катушки",
"labels.sortId": "#",
"labels.sortNewest": "Самые новые",
"loading": "Загрузка",
"locations.locations": "Местоположения",
"locations.newLocation": "Новое местоположение",
@@ -83,8 +315,8 @@
"settings.extraFields.booleanFalse": "Нет",
"settings.extraFields.booleanTrue": "Да",
"settings.extraFields.deleteConfirm": "Удалить поле {{name}}?",
"settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.",
"settings.extraFields.description.constraints": "Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.",
"settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.",
"settings.extraFields.description.keyUsage": "Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.",
"settings.extraFields.description.tableViews": "Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.",
"settings.extraFields.fieldType.boolean": "Логический",
+35 -5
View File
@@ -1,5 +1,5 @@
import type { SearchMatch, SearchResults } from './types';
import type { Filament, Spool, Vendor } from '$lib/types';
import type { FilamentSearchMatch, FilamentSpoolHit, SearchMatch, SearchResults } from './types';
import type { Spool, Vendor } from '$lib/types';
import { getJson } from './http';
import { mapFilament, mapSpool, mapVendor } from './map';
import { inventory } from '$lib/stores/inventory.svelte';
@@ -8,6 +8,11 @@ import { inventory } from '$lib/stores/inventory.svelte';
// entity it fetches is upserted into the reactive cache so that clicking a result
// (which opens its inspector) has the data ready immediately.
// Spools to request per matching filament, shown as shortcut pills under the
// filament (issue #993: searching a filament name should lead to a spool). Kept
// small: the panel only has room for a few, and every keystroke re-queries.
const SPOOLS_PER_FILAMENT = 5;
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
@@ -24,12 +29,29 @@ function mapSpoolHit(m: Json): SearchMatch<Spool> {
return { entity: spool, matchField: m.match_field };
}
function mapFilamentHit(m: Json): SearchMatch<Filament> {
function mapFilamentSpool(s: Json): FilamentSpoolHit {
return {
id: s.id,
remainingWeight: s.remaining_weight ?? undefined,
location: s.location ?? undefined,
archived: !!s.archived
};
}
function mapFilamentHit(m: Json): FilamentSearchMatch {
const raw: Json = m.filament ?? {};
if (raw.vendor) inventory.upsertVendor(mapVendor(raw.vendor));
const filament = mapFilament(raw);
inventory.upsertFilament(filament);
return { entity: filament, matchField: m.match_field };
// These are too partial to upsert into the inventory cache; opening one fetches
// the full spool (see Inspector.svelte), which is what a plain deep link does too.
const spools = (m.spools ?? []).map(mapFilamentSpool);
return {
entity: filament,
matchField: m.match_field,
spools,
spoolCount: m.spool_count ?? spools.length
};
}
function mapVendorHit(m: Json): SearchMatch<Vendor> {
@@ -50,7 +72,15 @@ export async function searchAll(
const q = query.trim();
if (!q) return EMPTY;
const data = await getJson<Json>('/search', { q, color_similarity_threshold: threshold }, signal);
const data = await getJson<Json>(
'/search',
{
q,
color_similarity_threshold: threshold,
spools_per_filament: SPOOLS_PER_FILAMENT
},
signal
);
return {
spools: (data.spools ?? []).map(mapSpoolHit),
+16 -1
View File
@@ -100,10 +100,25 @@ export interface SearchMatch<T> {
matchField: string;
}
/** One of a matched filament's spools, in the fields a shortcut pill shows. */
export interface FilamentSpoolHit {
id: number;
remainingWeight?: number;
location?: string;
archived: boolean;
}
/** A filament search hit, plus the spools offered as shortcuts beneath it. */
export interface FilamentSearchMatch extends SearchMatch<Filament> {
spools: FilamentSpoolHit[];
/** Total spools of this filament, of which `spools` holds at most the first few. */
spoolCount: number;
}
/** Categorized results of a cross-entity search. */
export interface SearchResults {
spools: SearchMatch<Spool>[];
filaments: SearchMatch<Filament>[];
filaments: FilamentSearchMatch[];
vendors: SearchMatch<Vendor>[];
/** True when the query was recognized as a color (a threshold slider is relevant). */
isColorQuery: boolean;
@@ -1,8 +1,16 @@
<script lang="ts">
let { label }: { label: string } = $props();
interface Props {
label: string;
/** `solid` is the default chip. `outline` is for places where a filled chip
* would read as the same kind of thing as a neighbouring filled chip that
* says something else entirely — see the search panel's "matched" tag. */
variant?: 'solid' | 'outline';
}
let { label, variant = 'solid' }: Props = $props();
</script>
<span class="badge mono">{label}</span>
<span class="badge mono" class:outline={variant === 'outline'}>{label}</span>
<style>
.badge {
@@ -13,4 +21,11 @@
color: var(--text-2);
white-space: nowrap;
}
.badge.outline {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 0 5px;
letter-spacing: 0.02em;
}
</style>
@@ -12,6 +12,7 @@
import { page } from '$app/state';
import { inventory } from '$lib/stores/inventory.svelte';
import { fields } from '$lib/stores/fields.svelte';
import { weightAuto } from '$lib/utils/format';
import type { EntityKind } from '$lib/types';
import * as m from '$lib/paraglide/messages';
@@ -100,14 +101,34 @@
interface FlatItem {
kind: EntityKind;
id: string;
/** Unique per row. A spool can be both its own result and a pill under a
* filament, so kind+id alone would highlight two rows at once. */
key: string;
}
let flat = $derived<FlatItem[]>(
results
? [
...results.spools.map((m) => ({ kind: 'spool' as const, id: String(m.entity.id) })),
...results.filaments.map((m) => ({ kind: 'filament' as const, id: m.entity.id })),
...results.vendors.map((m) => ({ kind: 'vendor' as const, id: m.entity.id }))
...results.spools.map((m) => ({
kind: 'spool' as const,
id: String(m.entity.id),
key: `spool:${m.entity.id}`
})),
// Each filament is followed by its spool pills, so arrowing down walks
// from the filament straight into its spools.
...results.filaments.flatMap((m) => [
{ kind: 'filament' as const, id: m.entity.id, key: `filament:${m.entity.id}` },
...m.spools.map((s) => ({
kind: 'spool' as const,
id: String(s.id),
key: `filament:${m.entity.id}/spool:${s.id}`
}))
]),
...results.vendors.map((m) => ({
kind: 'vendor' as const,
id: m.entity.id,
key: `vendor:${m.entity.id}`
}))
]
: []
);
@@ -194,9 +215,9 @@
return matchField in MATCH_LABEL[kind] ? MATCH_LABEL[kind][matchField]() : matchField;
}
// The flat index of an item, so hover/selection highlight lines up with keyboard nav.
function indexOf(kind: EntityKind, id: string): number {
return flat.findIndex((f) => f.kind === kind && f.id === id);
// The flat index of a row, so hover/selection highlight lines up with keyboard nav.
function indexOf(key: string): number {
return flat.findIndex((f) => f.key === key);
}
</script>
@@ -239,7 +260,7 @@
{@const filament = inventory.filamentById(spool.entity.filamentId)}
<a
class="result"
class:active={activeIndex === indexOf('spool', String(spool.entity.id))}
class:active={activeIndex === indexOf(`spool:${spool.entity.id}`)}
href={searchResultHref(
page.url.searchParams,
page.url.pathname,
@@ -266,7 +287,7 @@
{@const vendor = inventory.vendorById(filament.entity.vendorId)}
<a
class="result"
class:active={activeIndex === indexOf('filament', filament.entity.id)}
class:active={activeIndex === indexOf(`filament:${filament.entity.id}`)}
href={searchResultHref(
page.url.searchParams,
page.url.pathname,
@@ -282,12 +303,57 @@
size={20}
/>
<span class="text">
<span class="title">{filament.entity.name}</span>
<!-- Material sits with the name because it describes the filament, unlike
the trailing "matched" tag which describes the search hit. Two chips
of the same shape side by side read as the same kind of fact. -->
<span class="line">
<span class="title">{filament.entity.name}</span>
{#if filament.entity.material}
<MaterialBadge label={filament.entity.material} variant="outline" />
{/if}
</span>
{#if vendor?.name}<span class="sub">{vendor.name}</span>{/if}
</span>
{#if filament.entity.material}<MaterialBadge label={filament.entity.material} />{/if}
<span class="match">{matchLabel('filament', filament.matchField)}</span>
</a>
<!-- Shortcut straight to a spool of this filament (#993). Sibling of the
result anchor rather than inside it: an <a> may not nest. -->
{#if filament.spools.length}
<div class="pills">
{#each filament.spools as s (s.id)}
<a
class="pill"
class:active={activeIndex === indexOf(`filament:${filament.entity.id}/spool:${s.id}`)}
class:archived={s.archived}
href={searchResultHref(page.url.searchParams, page.url.pathname, 'spool', String(s.id))}
title={s.location ?? undefined}
data-sveltekit-noscroll
onclick={onResultClick}
>
<span class="mono">#{s.id}</span>
{#if s.remainingWeight !== undefined}<span class="pill-weight"
>{weightAuto(s.remainingWeight)}</span
>{/if}
</a>
{/each}
{#if filament.spoolCount > filament.spools.length}
<!-- The filament page lists them all, so send the overflow there. -->
<a
class="pill more"
href={searchResultHref(
page.url.searchParams,
page.url.pathname,
'filament',
filament.entity.id
)}
data-sveltekit-noscroll
onclick={onResultClick}
>
{m['search.moreSpools']({ count: filament.spoolCount - filament.spools.length })}
</a>
{/if}
</div>
{/if}
{/each}
{/if}
@@ -296,7 +362,7 @@
{#each results.vendors as vendor (vendor.entity.id)}
<a
class="result"
class:active={activeIndex === indexOf('vendor', vendor.entity.id)}
class:active={activeIndex === indexOf(`vendor:${vendor.entity.id}`)}
href={searchResultHref(page.url.searchParams, page.url.pathname, 'vendor', vendor.entity.id)}
data-sveltekit-noscroll
onclick={onResultClick}
@@ -411,6 +477,12 @@
flex-direction: column;
line-height: 1.25;
}
.line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.title {
font-size: 12.5px;
font-weight: 600;
@@ -418,6 +490,10 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* A long name yields to the material chip rather than pushing it out of the row. */
.line .title {
min-width: 0;
}
.sub {
font-size: 11px;
color: var(--text-dim);
@@ -440,4 +516,43 @@
text-align: center;
flex: none;
}
/* Indented to line up with the filament's title, so the pills read as belonging
to the result above rather than as results of their own. */
.pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px 7px 41px;
}
.pill {
display: inline-flex;
align-items: baseline;
gap: 5px;
padding: 2px 7px;
border: 1px solid var(--hairline);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-dim);
font-size: 11px;
text-decoration: none;
white-space: nowrap;
}
.pill:hover,
.pill.active {
border-color: var(--border-strong);
background: var(--surface-2);
color: var(--text);
}
.pill .mono {
font-size: 11px;
}
.pill-weight {
color: var(--text-muted);
}
.pill.archived {
opacity: 0.55;
}
.pill.more {
border-style: dashed;
}
</style>
@@ -12,6 +12,7 @@
import * as m from '$lib/paraglide/messages';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
const ENTITIES: { key: EntityType; label: () => string }[] = [
{ key: 'spool', label: m['library.section.spool'] },
@@ -144,6 +145,38 @@
choices = choices.filter((x) => x !== c);
}
// Choices may not be removed once saved, but their order is free to change —
// it is the order they are offered in wherever the field is used.
let draggedChoice = $state<string | null>(null);
function moveChoice(choice: string, before: string) {
const next = [...choices];
const from = next.indexOf(choice);
const to = next.indexOf(before);
if (from === -1 || to === -1 || from === to) return;
next.splice(from, 1);
next.splice(to, 0, choice);
choices = next;
}
function moveChoiceBy(choice: string, offset: number) {
const to = choices.indexOf(choice) + offset;
if (to < 0 || to >= choices.length) return;
moveChoice(choice, choices[to]);
}
function onChoiceDragOver(e: DragEvent, target: string) {
if (draggedChoice === null) return;
e.preventDefault();
moveChoice(draggedChoice, target);
}
function onGripKeydown(e: KeyboardEvent, choice: string) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
moveChoiceBy(choice, e.key === 'ArrowLeft' ? -1 : 1);
}
async function save() {
error = '';
if (!/^[a-z0-9_]+$/.test(key)) {
@@ -303,7 +336,24 @@
<span>{m['settings.extraFields.params.choices']()}</span>
<div class="chips">
{#each choices as c (c)}
<span class="chip">
<span
class="chip"
class:dragging={draggedChoice === c}
role="group"
aria-label={c}
ondragover={(e) => onChoiceDragOver(e, c)}
>
<button
class="chip-grip"
draggable="true"
ondragstart={() => (draggedChoice = c)}
ondragend={() => (draggedChoice = null)}
onkeydown={(e) => onGripKeydown(e, c)}
title={m['settings.extraFields.reorderChoice']()}
aria-label={m['settings.extraFields.reorderChoice']()}
>
<GripVertical size={11} />
</button>
{c}
{#if isNew || !originalChoices.includes(c)}
<button class="chip-x" onclick={() => removeChoice(c)} aria-label={m['common.remove']()}
@@ -520,6 +570,17 @@
padding: 2px 7px;
font-size: 12px;
}
.chip.dragging {
opacity: 0.4;
}
.chip-grip {
display: inline-flex;
background: none;
border: none;
color: var(--accent-muted);
cursor: grab;
padding: 0;
}
.chip-x {
background: none;
border: none;
@@ -0,0 +1,51 @@
"""strip color hex hash.
Revision ID: 9c1d5f2a7b31
Revises: 415a8f855e14
Create Date: 2026-08-02 09:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "9c1d5f2a7b31"
down_revision = "415a8f855e14"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Strip leading # characters from stored filament color codes."""
"""Rows are rewritten one by one through the connection rather than with SQL"""
"""string functions, so this behaves identically on sqlite, postgres, mysql"""
"""and cockroachdb (see 304a32906234 for the cockroachdb caveat)."""
filament = sa.Table(
"filament",
sa.MetaData(),
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("color_hex", sa.String(length=8), nullable=True),
sa.Column("multi_color_hexes", sa.String(length=128), nullable=True),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(filament.c.id, filament.c.color_hex, filament.c.multi_color_hexes),
).fetchall()
for row in rows:
values = {}
if row.color_hex is not None and "#" in row.color_hex:
values["color_hex"] = row.color_hex.replace("#", "")
if row.multi_color_hexes is not None and "#" in row.multi_color_hexes:
values["multi_color_hexes"] = row.multi_color_hexes.replace("#", "")
if values:
connection.execute(sa.update(filament).where(filament.c.id == row.id).values(**values))
def downgrade() -> None:
"""Perform the downgrade."""
# The leading # was never valid, so there is nothing to restore.
+5 -2
View File
@@ -142,7 +142,7 @@ class FilamentParameters(BaseModel):
if len(clr) not in (6, 8):
raise ValueError("Color code must be 6 or 8 characters long.")
return v
return v.removeprefix("#")
@field_validator("multi_color_hexes")
@classmethod
@@ -150,6 +150,7 @@ class FilamentParameters(BaseModel):
"""Validate the multi_color_hexes field."""
if not v:
return None
colors = []
for clr_raw in v.split(","):
clr = clr_raw.upper()
clr = clr.removeprefix("#")
@@ -161,7 +162,9 @@ class FilamentParameters(BaseModel):
if len(clr) not in (6, 8):
raise ValueError("Color code must be 6 or 8 characters long.")
return v
colors.append(clr_raw.removeprefix("#"))
return ",".join(colors)
@model_validator(mode="after") # type: ignore[]
def validate(self) -> "FilamentParameters":
+87 -3
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
from enum import Enum
from typing import Annotated, Literal
from typing import TYPE_CHECKING, Annotated, Literal
from pydantic import BaseModel, Field, PlainSerializer
@@ -10,6 +10,11 @@ from spoolman.database import models
from spoolman.math import length_from_weight
from spoolman.settings import SettingDefinition, SettingType
if TYPE_CHECKING:
# Only for typing: spoolman.database.search reaches spoolman.database.filament,
# which imports this module, so importing it for real would be circular.
from spoolman.database import search
def datetime_to_str(dt: datetime) -> str:
"""Convert a datetime object to a string."""
@@ -21,6 +26,31 @@ def datetime_to_str(dt: datetime) -> str:
SpoolmanDateTime = Annotated[datetime, PlainSerializer(datetime_to_str)]
def _sanitize_color_hex(value: str | None) -> str | None:
"""Normalize a color code read from the database.
Older releases could store a value with a leading ``#`` (see #780). Strip it and
drop anything that still isn't a valid 6 or 8 character code, so that one bad row
doesn't make the whole filament list unserializable.
"""
if not value:
return None
clr = value.upper().removeprefix("#")
if len(clr) not in (6, 8) or any(c not in "0123456789ABCDEF" for c in clr):
return None
return clr
def _sanitize_multi_color_hexes(value: str | None) -> str | None:
"""Normalize a comma-separated list of color codes read from the database."""
if not value:
return None
colors = [c for c in (_sanitize_color_hex(part) for part in value.split(",")) if c is not None]
if len(colors) < 2: # noqa: PLR2004
return None
return ",".join(colors)
def _extra_fields_description(entity: str) -> str:
r"""Build the description for an entity's ``extra`` field.
@@ -242,8 +272,8 @@ class Filament(BaseModel):
comment=item.comment,
settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp,
color_hex=item.color_hex,
multi_color_hexes=item.multi_color_hexes,
color_hex=_sanitize_color_hex(item.color_hex),
multi_color_hexes=_sanitize_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
),
@@ -441,6 +471,44 @@ class SearchResultSpool(BaseModel):
)
class SearchResultFilamentSpool(BaseModel):
"""A spool of a filament that matched a search, in the fields needed to offer it as a shortcut."""
id: int = Field(description="Unique internal ID of this spool of filament.")
remaining_weight: float | None = Field(
default=None,
ge=0,
description=(
"Estimated remaining weight of filament on the spool in grams. "
"Only set if the spool or its filament type has a weight set."
),
examples=[500.6],
)
location: str | None = Field(
None,
max_length=64,
description="Where this spool can be found.",
examples=["Shelf A"],
)
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
@staticmethod
def from_db(item: "search.FilamentSpool", filament_weight: float | None) -> "SearchResultFilamentSpool":
"""Create the compact spool object, deriving its weight the way `Spool.from_db` does."""
remaining_weight: float | None = None
if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0)
elif filament_weight is not None:
remaining_weight = max(filament_weight - item.used_weight, 0)
return SearchResultFilamentSpool(
id=item.id,
remaining_weight=remaining_weight,
location=item.location,
archived=item.archived,
)
class SearchResultFilament(BaseModel):
"""A filament that matched a search, with which field matched."""
@@ -452,6 +520,22 @@ class SearchResultFilament(BaseModel):
),
examples=["color"],
)
spools: list[SearchResultFilamentSpool] | None = Field(
default=None,
description=(
"The filament's first spools, oldest id first, so a filament hit can be followed "
"straight to one of its spools. Only present if spools_per_filament was requested. "
"Obeys allow_archived like the rest of the response."
),
)
spool_count: int | None = Field(
default=None,
description=(
"How many spools this filament has in total, of which `spools` holds at most "
"spools_per_filament. Only present if spools_per_filament was requested."
),
examples=[3],
)
class SearchResultVendor(BaseModel):
+26 -2
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import (
Filament,
SearchResultFilament,
SearchResultFilamentSpool,
SearchResults,
SearchResultSpool,
SearchResultVendor,
@@ -41,7 +42,8 @@ router = APIRouter(
"is a hex code (e.g. '#ff0000') or a CSS color name (e.g. 'red') additionally runs a "
"color-similarity search over filaments. Results are categorized by entity and each result "
"reports which field matched. Archived spools are excluded unless allow_archived is set, "
"matching the behavior of the spool endpoint."
"matching the behavior of the spool endpoint. Set spools_per_filament to also get each "
"matching filament's first few spools, so a filament hit leads straight to a spool."
),
response_model_exclude_none=True,
)
@@ -87,6 +89,18 @@ async def search_endpoint(
description="Whether to include archived spools in the results.",
),
] = False,
spools_per_filament: Annotated[
int,
Query(
title="Spools Per Filament",
description=(
"How many of each matching filament's spools to include with it, so a filament hit "
"can be followed straight to one of its spools. 0 (the default) omits them entirely."
),
ge=0,
le=10,
),
] = 0,
) -> SearchResults:
result = await search.search(
db=db,
@@ -94,11 +108,21 @@ async def search_endpoint(
color_similarity_threshold=color_similarity_threshold,
limit=limit,
allow_archived=allow_archived,
spools_per_filament=spools_per_filament,
)
return SearchResults(
spools=[SearchResultSpool(spool=Spool.from_db(m.spool), match_field=m.match_field) for m in result.spools],
filaments=[
SearchResultFilament(filament=Filament.from_db(m.filament), match_field=m.match_field)
SearchResultFilament(
filament=Filament.from_db(m.filament),
match_field=m.match_field,
spools=(
None
if m.spools is None
else [SearchResultFilamentSpool.from_db(s, m.filament.weight) for s in m.spools]
),
spool_count=m.spool_count,
)
for m in result.filaments
],
vendors=[
+89
View File
@@ -63,10 +63,25 @@ class SpoolMatch:
match_field: str
@dataclass
class FilamentSpool:
"""A spool of a matched filament, in the few raw columns a search result needs."""
id: int
initial_weight: float | None
used_weight: float
location: str | None
archived: bool
@dataclass
class FilamentMatch:
filament: models.Filament
match_field: str
# Both stay None unless the caller asked for spools, so "not requested" is
# distinguishable from "this filament has no spools".
spools: list[FilamentSpool] | None = None
spool_count: int | None = None
@dataclass
@@ -332,6 +347,76 @@ async def _search_filaments(
return [FilamentMatch(filament=filaments[fid], match_field=field) for fid, (field, _rank) in ordered]
async def _attach_filament_spools(
db: AsyncSession,
matches: list[FilamentMatch],
per_filament: int,
*,
allow_archived: bool,
) -> None:
"""Annotate each filament match with its first few spools, and how many it has in total.
A filament hit answers "you own this filament"; the spools answer "and here is
the one to click". Searching a filament name is the common way to reach a spool
(issue #993), and without this the search box can only offer the filament.
One query fetches the few scalar columns of every spool of every matched filament,
then the rows are grouped, counted and trimmed in Python. That is bounded by the
spools of at most ``limit`` filaments -- a handful of rows for any real inventory --
and it keeps the exact total for free. The portable alternative for a per-group
limit is a window function, which is deliberately avoided: this has to behave
identically on SQLite, PostgreSQL, MySQL/MariaDB and CockroachDB.
Spools are ordered by id, so the "first" spools are the oldest ones, the same
numbering the user sees on the spool itself. Ordering by ``last_used`` would read
better but sorts nulls differently across those four databases.
"""
if not matches or per_filament <= 0:
return
by_id = {m.filament.id: m for m in matches}
stmt = (
select(
models.Spool.filament_id,
models.Spool.id,
models.Spool.initial_weight,
models.Spool.used_weight,
models.Spool.location,
models.Spool.archived,
)
.where(models.Spool.filament_id.in_(by_id.keys()))
.order_by(models.Spool.id)
)
if not allow_archived:
# Same archived clause as everywhere else here, so the pills can't offer a
# spool that the spool section of the very same response is hiding.
stmt = stmt.where(
or_(
models.Spool.archived.is_(False),
models.Spool.archived.is_(None),
),
)
counts: dict[int, int] = dict.fromkeys(by_id, 0)
spools: dict[int, list[FilamentSpool]] = {fid: [] for fid in by_id}
for filament_id, spool_id, initial_weight, used_weight, location, archived in (await db.execute(stmt)).all():
counts[filament_id] += 1
if len(spools[filament_id]) < per_filament:
spools[filament_id].append(
FilamentSpool(
id=spool_id,
initial_weight=initial_weight,
used_weight=used_weight,
location=location,
archived=bool(archived),
),
)
for filament_id, match in by_id.items():
match.spools = spools[filament_id]
match.spool_count = counts[filament_id]
async def _search_vendors(db: AsyncSession, terms: list[str], limit: int) -> list[VendorMatch]:
native_fields = (
("name", models.Vendor.name),
@@ -409,6 +494,7 @@ async def search(
color_similarity_threshold: float = 20.0,
limit: int = 20,
allow_archived: bool = False,
spools_per_filament: int = 0,
) -> SearchResult:
"""Run a cross-entity search for ``query`` and return categorized, annotated results."""
q = query.strip()
@@ -425,6 +511,9 @@ async def search(
filaments = await _search_filaments(db, terms, limit, color_hits)
vendors = await _search_vendors(db, terms, limit)
# After trimming to `limit`, so we only fetch spools for filaments we return.
await _attach_filament_spools(db, filaments, spools_per_filament, allow_archived=allow_archived)
# Numeric query: surface the spool with that exact id at the very top.
if q.isdigit():
spool_id = int(q)
+105
View File
@@ -0,0 +1,105 @@
"""Tests for the filament color code validators.
The validators normalized a copy of the value (stripping a leading ``#``) but returned the
original string, so a ``#``-prefixed 8-digit color was stored with 9 characters and no longer
fit the ``String(8)`` column, breaking every later read of that filament.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
import pytest
from spoolman.api.v1.filament import FilamentParameters
from spoolman.api.v1.models import Filament, _sanitize_color_hex, _sanitize_multi_color_hexes
def _params(**kwargs: Any) -> FilamentParameters: # noqa: ANN401
return FilamentParameters(density=1.24, diameter=1.75, **kwargs)
@pytest.mark.parametrize(
("value", "expected"),
[
("FF0000", "FF0000"),
("#FF0000", "FF0000"),
("FF000000", "FF000000"),
("#FF000000", "FF000000"),
],
)
def test_color_hex_strips_leading_hash(value: str, expected: str):
assert _params(color_hex=value).color_hex == expected
@pytest.mark.parametrize(
("value", "expected"),
[
("FF0000,00FF00", "FF0000,00FF00"),
("#FF0000,#00FF00", "FF0000,00FF00"),
("#FF000000,00FF0000", "FF000000,00FF0000"),
],
)
def test_multi_color_hexes_strips_leading_hash(value: str, expected: str):
params = _params(multi_color_hexes=value, multi_color_direction="coaxial")
assert params.multi_color_hexes == expected
def test_color_hex_fits_the_database_column():
assert len(_params(color_hex="#FF000000").color_hex) <= 8
@pytest.mark.parametrize(
("stored", "expected"),
[
("FF0000", "FF0000"),
("#FF0000", "FF0000"),
("#FF000000", "FF000000"),
("#ff000000", "FF000000"),
("nonsense", None),
("#12345", None),
(None, None),
("", None),
],
)
def test_from_db_sanitizes_color_hex(stored: str | None, expected: str | None):
assert _sanitize_color_hex(stored) == expected
@pytest.mark.parametrize(
("stored", "expected"),
[
("FF0000,00FF00", "FF0000,00FF00"),
("#FF0000,#00FF00", "FF0000,00FF00"),
("#FF0000,garbage", None),
("#FF0000", None),
(None, None),
],
)
def test_from_db_sanitizes_multi_color_hexes(stored: str | None, expected: str | None):
assert _sanitize_multi_color_hexes(stored) == expected
def test_from_db_survives_a_row_written_before_the_fix():
item = SimpleNamespace(
id=1,
registered=datetime(2024, 1, 1, tzinfo=timezone.utc),
name="broken",
vendor=None,
material=None,
price=None,
density=1.24,
diameter=1.75,
weight=None,
spool_weight=None,
article_number=None,
comment=None,
settings_extruder_temp=None,
settings_bed_temp=None,
color_hex="#FF000000",
multi_color_hexes=None,
multi_color_direction=None,
external_id=None,
extra=[],
)
assert Filament.from_db(item).color_hex == "FF000000"
+15 -2
View File
@@ -97,6 +97,8 @@ export interface NewSpool {
usedG?: number;
/** Values for the filament's extra fields, keyed by their display name. */
filamentExtra?: Record<string, string>;
/** How many identical spools of the filament to create. Defaults to one. */
count?: number;
}
/**
@@ -111,7 +113,16 @@ export interface NewSpool {
* their own on top.
*/
export async function createSpoolViaModal(page: Page, spool: NewSpool): Promise<void> {
const { vendorName, filamentName, locationName, material = "PLA", weightG = 1000, usedG, filamentExtra } = spool;
const {
vendorName,
filamentName,
locationName,
material = "PLA",
weightG = 1000,
usedG,
filamentExtra,
count = 1,
} = spool;
const dialog = await openAddSpoolModal(page);
@@ -143,6 +154,7 @@ export async function createSpoolViaModal(page: Page, spool: NewSpool): Promise<
}
await expect(numberField(dialog, "Count")).toHaveValue("1");
if (count !== 1) await numberField(dialog, "Count").fill(String(count));
await numberField(dialog, "Weight").fill(String(weightG));
await dialog.getByPlaceholder("e.g. Shelf A").fill(locationName);
@@ -153,7 +165,8 @@ export async function createSpoolViaModal(page: Page, spool: NewSpool): Promise<
await dialog.getByPlaceholder("0", { exact: true }).fill(String(usedG));
}
await dialog.getByRole("button", { name: "Add 1 spool", exact: true }).click();
const submitLabel = count === 1 ? "Add 1 spool" : `Add ${count} spools`;
await dialog.getByRole("button", { name: submitLabel, exact: true }).click();
// A successful submit closes the modal.
await expect(dialog).toBeHidden();
+30
View File
@@ -75,6 +75,36 @@ test("the top-bar search covers all three entities", async ({ page }) => {
});
});
test("a filament result offers its spools as shortcuts", async ({ page }) => {
// Issue #993: a spool only matches on its own text, so searching a filament
// name used to be a dead end — the hit named the filament you own but gave no
// way to reach any of its spools. Each filament hit now carries the first few.
const many = {
vendorName: unique("PillVendor"),
filamentName: unique("PillFilament"),
locationName: unique("PillShelf"),
count: 6,
};
await openApp(page);
await createSpoolViaModal(page, many);
const panel = await searchFor(page, many.filamentName);
await expect(panel.getByText(many.filamentName, { exact: true })).toBeVisible();
// Six spools, five pills: the rest are behind an overflow link to the filament,
// which lists all of them.
const pills = panel.locator(".pills a:not(.more)");
await expect(pills).toHaveCount(5);
await expect(pills.first()).toHaveText(/^#\d+/);
await expect(panel.locator(".pills a.more")).toHaveText("+1 more");
// The point of the whole thing: one click from a filament name to a spool.
await pills.first().click();
await expect(page).toHaveURL(/[?&]sel=spool(:|%3A)\d+/);
await expect(panel).toBeHidden();
});
test("a search that matches nothing says so", async ({ page }) => {
await openApp(page);
@@ -102,12 +102,15 @@ def _search(
color_similarity_threshold: float | None = None,
*,
allow_archived: bool | None = None,
spools_per_filament: int | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {"q": query}
if color_similarity_threshold is not None:
params["color_similarity_threshold"] = color_similarity_threshold
if allow_archived is not None:
params["allow_archived"] = allow_archived
if spools_per_filament is not None:
params["spools_per_filament"] = spools_per_filament
result = httpx.get(f"{URL}/api/v1/search", params=params)
result.raise_for_status()
return result.json()
@@ -238,3 +241,85 @@ def test_search_by_id_respects_allow_archived(archived_spool: dict[str, Any]):
spool_id = archived_spool["id"]
assert not _has(_search(str(spool_id))["spools"], "spool", spool_id, "id")
assert _has(_search(str(spool_id), allow_archived=True)["spools"], "spool", spool_id, "id")
# --- spools attached to filament results (issue #993) -------------------------------
@pytest.fixture
def extra_spools(data: Fixture) -> Iterable[list[dict[str, Any]]]:
"""Two more spools of the fixture's filament, so it has three in total."""
spools = []
for _ in range(2):
result = httpx.post(
f"{URL}/api/v1/spool",
json={"filament_id": data.filament["id"], "remaining_weight": 600},
)
result.raise_for_status()
spools.append(result.json())
yield spools
for spool in spools:
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
def _filament_hit(body: dict[str, Any], filament_id: int) -> dict[str, Any]:
return next(i for i in body["filaments"] if i["filament"]["id"] == filament_id)
def test_filament_spools_omitted_by_default(data: Fixture, extra_spools: list[dict[str, Any]]):
"""Not asking for spools leaves the response exactly as it was before."""
assert extra_spools # the filament does have spools to omit
hit = _filament_hit(_search(FILAMENT_NAME), data.filament["id"])
assert "spools" not in hit
assert "spool_count" not in hit
def test_filament_spools_returned_when_requested(data: Fixture, extra_spools: list[dict[str, Any]]):
"""A filament hit carries its spools, oldest id first, plus the total count."""
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=5), data.filament["id"])
expected = sorted([data.spool["id"], *(s["id"] for s in extra_spools)])
assert [s["id"] for s in hit["spools"]] == expected
assert hit["spool_count"] == 3
def test_filament_spools_have_remaining_weight(data: Fixture, extra_spools: list[dict[str, Any]]):
"""The compact spool derives the same remaining weight the spool endpoint reports."""
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=5), data.filament["id"])
by_id = {s["id"]: s for s in hit["spools"]}
assert by_id[data.spool["id"]]["remaining_weight"] == pytest.approx(1000)
assert by_id[extra_spools[0]["id"]]["remaining_weight"] == pytest.approx(600)
def test_filament_spools_location(data: Fixture):
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=5), data.filament["id"])
assert hit["spools"][0]["location"] == SPOOL_LOCATION
def test_filament_spools_respect_the_requested_count(data: Fixture, extra_spools: list[dict[str, Any]]):
"""Only the first N come back, but the count still reports every spool."""
assert extra_spools
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=2), data.filament["id"])
assert len(hit["spools"]) == 2
assert hit["spool_count"] == 3
def test_filament_spools_zero_means_omitted(data: Fixture):
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=0), data.filament["id"])
assert "spools" not in hit
def test_filament_spools_exclude_archived_by_default(data: Fixture, archived_spool: dict[str, Any]):
"""An archived spool must not be offered as a shortcut, nor counted."""
hit = _filament_hit(_search(FILAMENT_NAME, spools_per_filament=10), data.filament["id"])
assert archived_spool["id"] not in [s["id"] for s in hit["spools"]]
assert hit["spool_count"] == 1
def test_filament_spools_include_archived_when_allowed(data: Fixture, archived_spool: dict[str, Any]):
hit = _filament_hit(
_search(FILAMENT_NAME, allow_archived=True, spools_per_filament=10),
data.filament["id"],
)
archived = next(s for s in hit["spools"] if s["id"] == archived_spool["id"])
assert archived["archived"] is True
assert hit["spool_count"] == 2