mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-19 00:29:46 -05:00
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:
commit
ae12072cdd
18 files changed
+1106
-45
No files matched your search
@@ -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}}?",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Логический",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
@@ -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":
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user