feat: Add 3dfilamentprofiles.com integration (Issue #7)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Backend: Added ExternalSource enum, Filament3DFP model, and transform function - Backend: Updated _sync() to fetch from both SpoolmanDB and 3dfilamentprofiles - Frontend: Added source tabs to FilamentImportModal (All/SpoolmanDB/3DFP) - Frontend: Updated ExternalFilament interface with source and temp range fields - Frontend: Updated filament import handler to use temp range fields Also includes: - Dev mode styling (teal theme, DEV badge and banner) - Translation keys for source filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
74
CLAUDE.md
74
CLAUDE.md
@@ -132,7 +132,9 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
|||||||
| 4 | Layout Max-Width | ✅ DONE |
|
| 4 | Layout Max-Width | ✅ DONE |
|
||||||
| 5 | Temperature Ranges | ✅ DONE |
|
| 5 | Temperature Ranges | ✅ DONE |
|
||||||
| 6 | Spool Adjustment History | ✅ DONE |
|
| 6 | Spool Adjustment History | ✅ DONE |
|
||||||
| 7 | 3dfilamentprofiles Integration | Open |
|
| 7 | 3dfilamentprofiles Integration | ✅ DONE |
|
||||||
|
| 8 | (see Gitea #8) | ✅ DONE |
|
||||||
|
| 9 | Hierarchical Locations (Room/Bin) | Open |
|
||||||
|
|
||||||
### Issue #1 Complete - Inline Creation Modals
|
### Issue #1 Complete - Inline Creation Modals
|
||||||
|
|
||||||
@@ -213,3 +215,73 @@ Frontend:
|
|||||||
- `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface
|
- `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface
|
||||||
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
|
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
|
||||||
- `client/public/locales/en/common.json` - Translation keys for history table
|
- `client/public/locales/en/common.json` - Translation keys for history table
|
||||||
|
|
||||||
|
### Issue #7 Complete - 3dfilamentprofiles Integration
|
||||||
|
|
||||||
|
Added 3dfilamentprofiles.com as a second external database source alongside SpoolmanDB.
|
||||||
|
|
||||||
|
Backend (`spoolman/externaldb.py`):
|
||||||
|
- Added `ExternalSource` enum (SPOOLMANDB, FILAMENT_PROFILES_3D)
|
||||||
|
- Added `Filament3DFP` model for raw 3dfp data
|
||||||
|
- Added `_transform_3dfp_to_external()` to convert 3dfp format to ExternalFilament
|
||||||
|
- Updated `_sync()` to fetch from both sources and merge results
|
||||||
|
- Environment variables: `EXTERNAL_DB_3DFP_URL`, `EXTERNAL_DB_3DFP_ENABLED`
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- `client/src/utils/queryExternalDB.ts` - Added `ExternalSource` enum, `source` field, temp range fields
|
||||||
|
- `client/src/components/filamentImportModal.tsx` - Added tabs to filter by source (All/SpoolmanDB/3DFP)
|
||||||
|
- `client/src/pages/filaments/create.tsx` - Updated import handler for temp range fields
|
||||||
|
|
||||||
|
Data mapping (3dfp → Spoolman):
|
||||||
|
- `brand_name` → `manufacturer`
|
||||||
|
- `color` + `material_type` → `name`
|
||||||
|
- `rgb` → `color_hex` (strip # prefix)
|
||||||
|
- `properties.temp_min/max` → `extruder_temp/extruder_temp_max`
|
||||||
|
- `properties.bed_temp_min/max` → `bed_temp/bed_temp_max`
|
||||||
|
- Diameter defaults to 1.75mm (3dfp doesn't provide)
|
||||||
|
- Density from material lookup table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
To run locally against the Unraid PostgreSQL database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create virtualenv and install deps
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e ".[postgres]"
|
||||||
|
|
||||||
|
# Start backend (reads .env file)
|
||||||
|
source .venv/bin/activate
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
uvicorn spoolman.main:app --host 0.0.0.0 --port 7912
|
||||||
|
|
||||||
|
# Start frontend (separate terminal)
|
||||||
|
cd client
|
||||||
|
VITE_APIURL=http://localhost:7912/api/v1 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at http://localhost:5173
|
||||||
|
|
||||||
|
### Dev Mode Styling
|
||||||
|
|
||||||
|
Files modified for dev version visual distinction:
|
||||||
|
- `client/src/contexts/color-mode/index.tsx` - Teal primary color (#0891b2)
|
||||||
|
- `client/src/components/layout.tsx` - "DEV BUILD" banner and badge
|
||||||
|
|
||||||
|
Set `IS_DEV = false` in layout.tsx to disable dev styling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues / TODO
|
||||||
|
|
||||||
|
### Filament Select Not Refreshing After Inline Creation
|
||||||
|
When creating a filament via the inline modal on spool create page, the select dropdown doesn't immediately show the new filament with its name (shows ID instead).
|
||||||
|
|
||||||
|
Attempted fixes:
|
||||||
|
- Added `refetch` from `useGetFilamentSelectOptions` hook
|
||||||
|
- Called `await refetchFilaments()` before setting form value
|
||||||
|
|
||||||
|
Still not working reliably. May need to investigate react-query cache invalidation timing or add the new option manually to the select options array.
|
||||||
|
|||||||
@@ -276,6 +276,8 @@
|
|||||||
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
|
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
|
||||||
"import_external": "Import from External",
|
"import_external": "Import from External",
|
||||||
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
|
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
|
||||||
|
"all_sources": "All Sources",
|
||||||
|
"select_filament": "Select a filament...",
|
||||||
"created_success": "Created filament \"{{name}}\"",
|
"created_success": "Created filament \"{{name}}\"",
|
||||||
"vendor_placeholder": "Select a manufacturer",
|
"vendor_placeholder": "Select a manufacturer",
|
||||||
"name_placeholder": "E.g. Matte Black",
|
"name_placeholder": "E.g. Matte Black",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useTranslate } from "@refinedev/core";
|
import { useTranslate } from "@refinedev/core";
|
||||||
import { Form, Modal, Select } from "antd";
|
import { Form, Modal, Select, Tabs } from "antd";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { formatFilamentLabel } from "../pages/spools/functions";
|
import { formatFilamentLabel } from "../pages/spools/functions";
|
||||||
import { searchMatches } from "../utils/filtering";
|
import { searchMatches } from "../utils/filtering";
|
||||||
import { ExternalFilament, useGetExternalDBFilaments } from "../utils/queryExternalDB";
|
import { ExternalFilament, ExternalSource, useGetExternalDBFilaments } from "../utils/queryExternalDB";
|
||||||
|
|
||||||
export function FilamentImportModal(props: {
|
export function FilamentImportModal(props: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,12 +13,25 @@ export function FilamentImportModal(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const [selectedSource, setSelectedSource] = useState<ExternalSource | "all">("all");
|
||||||
|
|
||||||
const externalFilaments = useGetExternalDBFilaments();
|
const externalFilaments = useGetExternalDBFilaments();
|
||||||
const filamentOptions =
|
|
||||||
externalFilaments.data?.map((item) => {
|
// Filter and format filaments based on selected source
|
||||||
|
const filamentOptions = useMemo(() => {
|
||||||
|
const filtered = externalFilaments.data?.filter((item) => {
|
||||||
|
if (selectedSource === "all") return true;
|
||||||
|
return item.source === selectedSource;
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const options = filtered.map((item) => {
|
||||||
|
// Add source indicator to label if showing all
|
||||||
|
const sourceIndicator = selectedSource === "all" && item.source === ExternalSource.FILAMENT_PROFILES_3D
|
||||||
|
? "[3DFP] "
|
||||||
|
: "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: formatFilamentLabel(
|
label: sourceIndicator + formatFilamentLabel(
|
||||||
item.name,
|
item.name,
|
||||||
item.diameter,
|
item.diameter,
|
||||||
item.manufacturer,
|
item.manufacturer,
|
||||||
@@ -28,8 +42,23 @@ export function FilamentImportModal(props: {
|
|||||||
value: item.id,
|
value: item.id,
|
||||||
item: item,
|
item: item,
|
||||||
};
|
};
|
||||||
}) ?? [];
|
});
|
||||||
filamentOptions.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
|
||||||
|
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
||||||
|
return options;
|
||||||
|
}, [externalFilaments.data, selectedSource]);
|
||||||
|
|
||||||
|
// Count filaments by source
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const spoolmandb = externalFilaments.data?.filter(f => f.source === ExternalSource.SPOOLMANDB || !f.source).length ?? 0;
|
||||||
|
const threeDFP = externalFilaments.data?.filter(f => f.source === ExternalSource.FILAMENT_PROFILES_3D).length ?? 0;
|
||||||
|
return { spoolmandb, threeDFP, total: spoolmandb + threeDFP };
|
||||||
|
}, [externalFilaments.data]);
|
||||||
|
|
||||||
|
const handleSourceChange = (key: string) => {
|
||||||
|
setSelectedSource(key as ExternalSource | "all");
|
||||||
|
form.resetFields(["filament"]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -37,7 +66,36 @@ export function FilamentImportModal(props: {
|
|||||||
open={props.isOpen}
|
open={props.isOpen}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
onCancel={() => props.onClose()}
|
onCancel={() => props.onClose()}
|
||||||
|
width={600}
|
||||||
>
|
>
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
i18nKey={"filament.form.import_external_description"}
|
||||||
|
components={{
|
||||||
|
br: <br />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={selectedSource}
|
||||||
|
onChange={handleSourceChange}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "all",
|
||||||
|
label: `${t("filament.form.all_sources")} (${counts.total})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExternalSource.SPOOLMANDB,
|
||||||
|
label: `SpoolmanDB (${counts.spoolmandb})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExternalSource.FILAMENT_PROFILES_3D,
|
||||||
|
label: `3D Filament Profiles (${counts.threeDFP})`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
form={form}
|
form={form}
|
||||||
@@ -49,21 +107,15 @@ export function FilamentImportModal(props: {
|
|||||||
props.onImport(filament);
|
props.onImport(filament);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
setSelectedSource("all");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
|
||||||
<Trans
|
|
||||||
i18nKey={"filament.form.import_external_description"}
|
|
||||||
components={{
|
|
||||||
br: <br />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<Form.Item name="filament" rules={[{ required: true }]}>
|
<Form.Item name="filament" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
options={filamentOptions}
|
options={filamentOptions}
|
||||||
showSearch
|
showSearch
|
||||||
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
|
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
|
||||||
|
placeholder={t("filament.form.select_filament")}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd";
|
import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd";
|
||||||
import { useTranslate } from "@refinedev/core";
|
import { useTranslate } from "@refinedev/core";
|
||||||
import { Button } from "antd";
|
import { Badge, Button } from "antd";
|
||||||
import { Footer } from "antd/es/layout/layout";
|
import { Footer } from "antd/es/layout/layout";
|
||||||
import Logo from "../icon.svg?react";
|
import Logo from "../icon.svg?react";
|
||||||
import { getBasePath } from "../utils/url";
|
import { getBasePath } from "../utils/url";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
import { Version } from "./version";
|
import { Version } from "./version";
|
||||||
|
|
||||||
|
// DEV VERSION indicator
|
||||||
|
const IS_DEV = true;
|
||||||
|
|
||||||
const SpoolmanFooter: React.FC = () => {
|
const SpoolmanFooter: React.FC = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
@@ -50,17 +53,50 @@ interface SpoolmanLayoutProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DevTitle: React.FC<{ collapsed: boolean }> = ({ collapsed }) => (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />
|
||||||
|
{IS_DEV && !collapsed && (
|
||||||
|
<Badge
|
||||||
|
count="DEV"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#0891b2",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginLeft: -8,
|
||||||
|
marginTop: -20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => (
|
export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => (
|
||||||
<ThemedLayout
|
<ThemedLayout
|
||||||
Header={() => <Header sticky />}
|
Header={() => <Header sticky />}
|
||||||
Sider={() => (
|
Sider={() => (
|
||||||
<ThemedSider
|
<ThemedSider
|
||||||
fixed
|
fixed
|
||||||
Title={({ collapsed }) => <ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />}
|
Title={({ collapsed }) => <DevTitle collapsed={collapsed} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
Footer={() => <SpoolmanFooter />}
|
Footer={() => <SpoolmanFooter />}
|
||||||
>
|
>
|
||||||
|
{IS_DEV && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, #0891b2 0%, #06b6d4 50%, #0891b2 100%)",
|
||||||
|
color: "white",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "4px 0",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚀 DEV BUILD - Connected to Unraid PostgreSQL
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 1400,
|
maxWidth: 1400,
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
|
|||||||
theme={{
|
theme={{
|
||||||
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
|
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: "#dc7734",
|
// DEV VERSION: Teal/cyan theme to distinguish from production orange
|
||||||
|
colorPrimary: "#0891b2",
|
||||||
|
colorInfo: "#0891b2",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -127,9 +127,9 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
|
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
|
||||||
multi_color_direction: filament.multi_color_direction,
|
multi_color_direction: filament.multi_color_direction,
|
||||||
settings_extruder_temp_min: filament.extruder_temp || undefined,
|
settings_extruder_temp_min: filament.extruder_temp || undefined,
|
||||||
settings_extruder_temp_max: filament.extruder_temp || undefined,
|
settings_extruder_temp_max: filament.extruder_temp_max || filament.extruder_temp || undefined,
|
||||||
settings_bed_temp_min: filament.bed_temp || undefined,
|
settings_bed_temp_min: filament.bed_temp || undefined,
|
||||||
settings_bed_temp_max: filament.bed_temp || undefined,
|
settings_bed_temp_max: filament.bed_temp_max || filament.bed_temp || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
import { Create, useForm } from "@refinedev/antd";
|
import { Create, useForm } from "@refinedev/antd";
|
||||||
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
|
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||||
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||||
import TextArea from "antd/es/input/TextArea";
|
import TextArea from "antd/es/input/TextArea";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -33,7 +33,6 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
|||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const extraFields = useGetFields(EntityType.spool);
|
const extraFields = useGetFields(EntityType.spool);
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
const invalidate = useInvalidate();
|
|
||||||
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
|
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
|
||||||
|
|
||||||
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
||||||
@@ -82,14 +81,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
|||||||
internalSelectOptions,
|
internalSelectOptions,
|
||||||
externalSelectOptions,
|
externalSelectOptions,
|
||||||
allExternalFilaments,
|
allExternalFilaments,
|
||||||
|
refetch: refetchFilaments,
|
||||||
} = useGetFilamentSelectOptions();
|
} = useGetFilamentSelectOptions();
|
||||||
|
|
||||||
const handleFilamentCreated = async (filament: IFilament) => {
|
const handleFilamentCreated = async (filament: IFilament) => {
|
||||||
// Invalidate filament cache so the select options refresh
|
// Refetch filament list so the select options include the new filament
|
||||||
await invalidate({
|
await refetchFilaments();
|
||||||
resource: "filament",
|
|
||||||
invalidates: ["list"],
|
|
||||||
});
|
|
||||||
// Select the newly created filament
|
// Select the newly created filament
|
||||||
form.setFieldValue("filament_id", filament.id);
|
form.setFieldValue("filament_id", filament.id);
|
||||||
setIsFilamentModalOpen(false);
|
setIsFilamentModalOpen(false);
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export function useGetFilamentSelectOptions() {
|
|||||||
internalSelectOptions: filamentSelectInternal,
|
internalSelectOptions: filamentSelectInternal,
|
||||||
externalSelectOptions: filamentSelectExternal,
|
externalSelectOptions: filamentSelectExternal,
|
||||||
allExternalFilaments: externalFilaments.data,
|
allExternalFilaments: externalFilaments.data,
|
||||||
|
refetch: internalFilaments.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,25 +22,33 @@ export enum Pattern {
|
|||||||
SPARKLE = "sparkle",
|
SPARKLE = "sparkle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ExternalSource {
|
||||||
|
SPOOLMANDB = "spoolmandb",
|
||||||
|
FILAMENT_PROFILES_3D = "3dfilamentprofiles",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalFilament {
|
export interface ExternalFilament {
|
||||||
id: string;
|
id: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
material: string;
|
material: string;
|
||||||
density: number;
|
density: number;
|
||||||
weight: number;
|
weight?: number;
|
||||||
spool_weight?: number;
|
spool_weight?: number;
|
||||||
spool_type?: SpoolType;
|
spool_type?: SpoolType;
|
||||||
diameter: number;
|
diameter: number;
|
||||||
color_hex?: string;
|
color_hex?: string;
|
||||||
color_hexes?: string[];
|
color_hexes?: string[];
|
||||||
extruder_temp?: number;
|
extruder_temp?: number;
|
||||||
|
extruder_temp_max?: number;
|
||||||
bed_temp?: number;
|
bed_temp?: number;
|
||||||
|
bed_temp_max?: number;
|
||||||
finish?: Finish;
|
finish?: Finish;
|
||||||
multi_color_direction?: MultiColorDirection;
|
multi_color_direction?: MultiColorDirection;
|
||||||
pattern?: Pattern;
|
pattern?: Pattern;
|
||||||
translucent: boolean;
|
translucent: boolean;
|
||||||
glow: boolean;
|
glow: boolean;
|
||||||
|
source?: ExternalSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalMaterial {
|
export interface ExternalMaterial {
|
||||||
|
|||||||
39
codex.md
Normal file
39
codex.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `spoolman/` holds the FastAPI backend, SQLAlchemy models, and API routes (`spoolman/api/v1/`).
|
||||||
|
- `client/` is the React + TypeScript UI (Vite), with pages under `client/src/pages/` and shared components under `client/src/components/`.
|
||||||
|
- `migrations/` contains Alembic migrations; versions live in `migrations/versions/`.
|
||||||
|
- `tests_integration/` holds pytest integration tests and Docker Compose configs for multiple databases.
|
||||||
|
- `scripts/` provides install/start helpers; `Dockerfile` and `entrypoint.sh` support container builds.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `pdm install` installs backend dependencies (including dev tools).
|
||||||
|
- `pdm run app` starts the API server (`uvicorn spoolman.main:app`).
|
||||||
|
- `pdm run itest` runs integration tests across all databases.
|
||||||
|
- `pdm run docs` generates API documentation.
|
||||||
|
- `python tests_integration/run.py sqlite postgres` runs tests for selected DBs.
|
||||||
|
- `cd client && npm ci` installs UI deps; `npm run dev` starts the UI dev server; `npm run build` creates a production build.
|
||||||
|
- `bash scripts/install.sh` bootstraps a local install; `bash scripts/start.sh` runs using `.env`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Python: Black + Ruff, 120-char lines (`pyproject.toml`). Prefer async patterns in backend code.
|
||||||
|
- TypeScript/React: follow existing conventions (pages in lowercase file names, shared components in `PascalCase`).
|
||||||
|
- Keep naming aligned with existing modules (e.g., `spool`, `filament`, `vendor`).
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- Tests use `pytest` with async support; files follow `test_*.py` and functions start with `test_`.
|
||||||
|
- Integration tests are the primary suite; run via `pdm run itest` or `python tests_integration/run.py`.
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
- Update models in `spoolman/database/models.py`.
|
||||||
|
- Start the server once, then generate a migration: `pdm run alembic revision -m "desc" --autogenerate`.
|
||||||
|
- Review and format migrations with Black/Ruff before committing.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Follow commit history conventions (e.g., `feat:`, `docs:`, `fix:`) and keep messages concise.
|
||||||
|
- PRs should include a short summary, testing performed, and screenshots for UI changes.
|
||||||
|
- Link relevant issues where applicable.
|
||||||
|
|
||||||
|
## Configuration Tips
|
||||||
|
- Copy `.env.example` to `.env` for local runs; key variables include `SPOOLMAN_DB_TYPE`, `SPOOLMAN_PORT`, and `VITE_APIURL`.
|
||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import hishel
|
import hishel
|
||||||
@@ -20,8 +20,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
|
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
|
||||||
|
DEFAULT_3DFP_URL = "https://raw.githubusercontent.com/jklewa/filament-profiles-data/main/"
|
||||||
DEFAULT_SYNC_INTERVAL = 3600
|
DEFAULT_SYNC_INTERVAL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalSource(Enum):
|
||||||
|
"""External database sources."""
|
||||||
|
|
||||||
|
SPOOLMANDB = "spoolmandb"
|
||||||
|
FILAMENT_PROFILES_3D = "3dfilamentprofiles"
|
||||||
|
|
||||||
controller = hishel.Controller(allow_stale=True)
|
controller = hishel.Controller(allow_stale=True)
|
||||||
try:
|
try:
|
||||||
cache_path = get_cache_dir() / "hishel"
|
cache_path = get_cache_dir() / "hishel"
|
||||||
@@ -62,7 +70,7 @@ class ExternalFilament(BaseModel):
|
|||||||
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
|
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
|
||||||
material: str = Field(description="Filament material.", examples=["PLA"])
|
material: str = Field(description="Filament material.", examples=["PLA"])
|
||||||
density: float = Field(description="Density in g/cm3.", examples=[1.23])
|
density: float = Field(description="Density in g/cm3.", examples=[1.23])
|
||||||
weight: float = Field(description="Net weight of a single spool.", examples=[1000])
|
weight: Optional[float] = Field(default=None, description="Net weight of a single spool.", examples=[1000])
|
||||||
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
|
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
|
||||||
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
|
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
|
||||||
diameter: float = Field(description="Filament in mm.", examples=[1.75])
|
diameter: float = Field(description="Filament in mm.", examples=[1.75])
|
||||||
@@ -77,7 +85,9 @@ class ExternalFilament(BaseModel):
|
|||||||
examples=[["2c3232", "5f5f5f"]],
|
examples=[["2c3232", "5f5f5f"]],
|
||||||
)
|
)
|
||||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
|
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
|
||||||
|
extruder_temp_max: Optional[int] = Field(default=None, description="Max extruder temp in °C.", examples=[220])
|
||||||
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
|
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
|
||||||
|
bed_temp_max: Optional[int] = Field(default=None, description="Max bed temp in °C.", examples=[60])
|
||||||
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
|
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
|
||||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -87,6 +97,10 @@ class ExternalFilament(BaseModel):
|
|||||||
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
|
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
|
||||||
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
|
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
|
||||||
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
||||||
|
source: ExternalSource = Field(
|
||||||
|
default=ExternalSource.SPOOLMANDB,
|
||||||
|
description="External database source this filament came from.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalFilamentsFile(RootModel):
|
class ExternalFilamentsFile(RootModel):
|
||||||
@@ -120,11 +134,146 @@ class ExternalMaterialsFile(RootModel):
|
|||||||
return self.root[index]
|
return self.root[index]
|
||||||
|
|
||||||
|
|
||||||
|
# 3dfilamentprofiles.com data models
|
||||||
|
class FilamentProperties3DFP(BaseModel):
|
||||||
|
"""Properties object from 3dfilamentprofiles."""
|
||||||
|
|
||||||
|
temp_min: Optional[int] = None
|
||||||
|
temp_max: Optional[int] = None
|
||||||
|
bed_temp_min: Optional[int] = None
|
||||||
|
bed_temp_max: Optional[int] = None
|
||||||
|
spool_weight: Optional[float] = None
|
||||||
|
flow_ratio: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Filament3DFP(BaseModel):
|
||||||
|
"""Raw filament data from 3dfilamentprofiles.com."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
brand_name: str
|
||||||
|
material: str
|
||||||
|
material_type: Optional[str] = None
|
||||||
|
color: str
|
||||||
|
rgb: Optional[str] = None
|
||||||
|
properties: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentsFile3DFP(BaseModel):
|
||||||
|
"""The filaments.json file from 3dfilamentprofiles."""
|
||||||
|
|
||||||
|
filaments: list[Filament3DFP]
|
||||||
|
|
||||||
|
|
||||||
|
# Material density defaults (used when 3dfilamentprofiles doesn't provide density)
|
||||||
|
MATERIAL_DENSITIES: dict[str, float] = {
|
||||||
|
"PLA": 1.24,
|
||||||
|
"ABS": 1.04,
|
||||||
|
"PETG": 1.27,
|
||||||
|
"NYLON": 1.52,
|
||||||
|
"TPU": 1.21,
|
||||||
|
"PC": 1.3,
|
||||||
|
"WOOD": 1.28,
|
||||||
|
"CF": 1.3,
|
||||||
|
"PC/ABS": 1.19,
|
||||||
|
"HIPS": 1.03,
|
||||||
|
"PVA": 1.23,
|
||||||
|
"ASA": 1.05,
|
||||||
|
"PP": 0.9,
|
||||||
|
"POM": 1.4,
|
||||||
|
"PMMA": 1.18,
|
||||||
|
"FPE": 2.16,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_density_for_material(material: str) -> float:
|
||||||
|
"""Get density for a material, defaulting to PLA's density if unknown."""
|
||||||
|
material_upper = material.upper()
|
||||||
|
for key, density in MATERIAL_DENSITIES.items():
|
||||||
|
if key in material_upper:
|
||||||
|
return density
|
||||||
|
return 1.24 # Default to PLA density
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_3dfp_to_external(filament: Filament3DFP) -> ExternalFilament:
|
||||||
|
"""Transform a 3dfilamentprofiles filament to ExternalFilament format."""
|
||||||
|
# Build name from material_type and color
|
||||||
|
name_parts = []
|
||||||
|
if filament.material_type:
|
||||||
|
name_parts.append(filament.material_type)
|
||||||
|
name_parts.append(filament.color)
|
||||||
|
name = " ".join(name_parts)
|
||||||
|
|
||||||
|
# Handle color hex (3dfp includes # prefix, SpoolmanDB doesn't)
|
||||||
|
color_hex = None
|
||||||
|
if filament.rgb:
|
||||||
|
color_hex = filament.rgb.lstrip("#").lower()
|
||||||
|
|
||||||
|
# Extract properties
|
||||||
|
props = filament.properties or {}
|
||||||
|
extruder_temp = props.get("temp_min")
|
||||||
|
extruder_temp_max = props.get("temp_max")
|
||||||
|
bed_temp = props.get("bed_temp_min")
|
||||||
|
bed_temp_max = props.get("bed_temp_max")
|
||||||
|
spool_weight = props.get("spool_weight")
|
||||||
|
|
||||||
|
# Determine finish from material_type
|
||||||
|
finish = None
|
||||||
|
if filament.material_type:
|
||||||
|
material_type_lower = filament.material_type.lower()
|
||||||
|
if "matte" in material_type_lower:
|
||||||
|
finish = Finish.MATTE
|
||||||
|
elif "glossy" in material_type_lower or "silk" in material_type_lower:
|
||||||
|
finish = Finish.GLOSSY
|
||||||
|
|
||||||
|
# Determine pattern from material_type
|
||||||
|
pattern = None
|
||||||
|
if filament.material_type:
|
||||||
|
material_type_lower = filament.material_type.lower()
|
||||||
|
if "marble" in material_type_lower:
|
||||||
|
pattern = Pattern.MARBLE
|
||||||
|
elif "sparkle" in material_type_lower or "glitter" in material_type_lower:
|
||||||
|
pattern = Pattern.SPARKLE
|
||||||
|
|
||||||
|
return ExternalFilament(
|
||||||
|
id=f"3dfp_{filament.id}",
|
||||||
|
manufacturer=filament.brand_name,
|
||||||
|
name=name,
|
||||||
|
material=filament.material,
|
||||||
|
density=_get_density_for_material(filament.material),
|
||||||
|
weight=None, # 3dfp doesn't provide filament weight
|
||||||
|
spool_weight=spool_weight,
|
||||||
|
spool_type=None,
|
||||||
|
diameter=1.75, # Default, 3dfp doesn't provide diameter
|
||||||
|
color_hex=color_hex,
|
||||||
|
color_hexes=None,
|
||||||
|
extruder_temp=extruder_temp,
|
||||||
|
extruder_temp_max=extruder_temp_max,
|
||||||
|
bed_temp=bed_temp,
|
||||||
|
bed_temp_max=bed_temp_max,
|
||||||
|
finish=finish,
|
||||||
|
multi_color_direction=None,
|
||||||
|
pattern=pattern,
|
||||||
|
translucent=False,
|
||||||
|
glow=False,
|
||||||
|
source=ExternalSource.FILAMENT_PROFILES_3D,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_external_db_url() -> str:
|
def get_external_db_url() -> str:
|
||||||
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
|
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
|
||||||
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
|
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_3dfp_url() -> str:
|
||||||
|
"""Get the 3dfilamentprofiles URL from environment variables."""
|
||||||
|
return os.getenv("EXTERNAL_DB_3DFP_URL", DEFAULT_3DFP_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def is_3dfp_enabled() -> bool:
|
||||||
|
"""Check if 3dfilamentprofiles sync is enabled."""
|
||||||
|
return os.getenv("EXTERNAL_DB_3DFP_ENABLED", "true").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
def get_external_db_sync_interval() -> int:
|
def get_external_db_sync_interval() -> int:
|
||||||
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
|
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
|
||||||
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
|
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
|
||||||
@@ -166,22 +315,70 @@ def get_materials_file() -> Path:
|
|||||||
return filecache.get_file("materials.json")
|
return filecache.get_file("materials.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_3dfp_filaments(data: bytes) -> list[Filament3DFP]:
|
||||||
|
"""Parse 3dfilamentprofiles JSON data."""
|
||||||
|
try:
|
||||||
|
parsed = FilamentsFile3DFP.model_validate_json(data)
|
||||||
|
return parsed.filaments
|
||||||
|
except Exception:
|
||||||
|
# Fallback: try parsing as raw list
|
||||||
|
import json
|
||||||
|
|
||||||
|
raw = json.loads(data)
|
||||||
|
if isinstance(raw, dict) and "filaments" in raw:
|
||||||
|
return [Filament3DFP.model_validate(f) for f in raw["filaments"]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _sync() -> None:
|
async def _sync() -> None:
|
||||||
logger.info("Syncing external DB.")
|
logger.info("Syncing external DB.")
|
||||||
|
|
||||||
url = get_external_db_url()
|
all_filaments: list[ExternalFilament] = []
|
||||||
|
|
||||||
filaments = _parse_filaments_from_bytes(await _download_file(urljoin(url, "filaments.json")))
|
# Sync SpoolmanDB
|
||||||
materials = _parse_materials_from_bytes(await _download_file(urljoin(url, "materials.json")))
|
spoolmandb_url = get_external_db_url()
|
||||||
|
if spoolmandb_url.strip():
|
||||||
|
try:
|
||||||
|
spoolmandb_filaments = _parse_filaments_from_bytes(
|
||||||
|
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
|
||||||
|
)
|
||||||
|
# Mark all SpoolmanDB filaments with source
|
||||||
|
for filament in spoolmandb_filaments.root:
|
||||||
|
filament.source = ExternalSource.SPOOLMANDB
|
||||||
|
all_filaments.extend(spoolmandb_filaments.root)
|
||||||
|
logger.info("SpoolmanDB synced: %d filaments", len(spoolmandb_filaments.root))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to sync SpoolmanDB: %s", e)
|
||||||
|
|
||||||
_write_to_local_cache("filaments.json", filaments.json().encode())
|
# Sync 3dfilamentprofiles
|
||||||
_write_to_local_cache("materials.json", materials.json().encode())
|
if is_3dfp_enabled():
|
||||||
|
url_3dfp = get_3dfp_url()
|
||||||
|
if url_3dfp.strip():
|
||||||
|
try:
|
||||||
|
raw_data = await _download_file(urljoin(url_3dfp, "sample-filaments.json"))
|
||||||
|
filaments_3dfp = _parse_3dfp_filaments(raw_data)
|
||||||
|
transformed = [_transform_3dfp_to_external(f) for f in filaments_3dfp]
|
||||||
|
all_filaments.extend(transformed)
|
||||||
|
logger.info("3dfilamentprofiles synced: %d filaments", len(transformed))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to sync 3dfilamentprofiles: %s", e)
|
||||||
|
|
||||||
logger.info(
|
# Write combined filaments
|
||||||
"External DB synced. Filaments: %d, Materials: %d",
|
combined = ExternalFilamentsFile(root=all_filaments)
|
||||||
len(filaments.root),
|
_write_to_local_cache("filaments.json", combined.model_dump_json().encode())
|
||||||
len(materials.root),
|
|
||||||
)
|
# Materials only from SpoolmanDB
|
||||||
|
if spoolmandb_url.strip():
|
||||||
|
try:
|
||||||
|
materials = _parse_materials_from_bytes(
|
||||||
|
await _download_file(urljoin(spoolmandb_url, "materials.json"))
|
||||||
|
)
|
||||||
|
_write_to_local_cache("materials.json", materials.model_dump_json().encode())
|
||||||
|
logger.info("Materials synced: %d", len(materials.root))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to sync materials: %s", e)
|
||||||
|
|
||||||
|
logger.info("External DB sync complete. Total filaments: %d", len(all_filaments))
|
||||||
|
|
||||||
|
|
||||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user