diff --git a/core/nodeslicer/__init__.py b/core/nodeslicer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index 85cc992..979fcd5 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -10,6 +10,7 @@ from typing import Any, TypeAlias import bpy import numpy as np from mathutils import Euler + from .bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed from ...registry import register_class @@ -20,6 +21,7 @@ from ...infra.blender.blender_bridge import get_all_children from ...infra.blender.blender_slice_nodes_generic import SliceNode, resolve_input from ...infra.prusaslicer_profiles.cache import LocalCache from ...infra.prusaslicer_profiles.models import Profile +from ...infra.networking.airtable_backend import AirtableRecord from ...infra._3mf_writer._3mf import prepare_3mf class SliceIntrinsics: @@ -1084,16 +1086,11 @@ class SLICENODE_StartPrint(ExecSliceNode): return 'ok' -@register_class -class SLICENODE_BaserowSubmit(ExecSliceNode): - bl_label = "Baserow Submit" - button_label = "Submit to Baserow" - - host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm] - token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] - table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] +class ExecSliceNode_DatabaseSubmit(ExecSliceNode): record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm] - gcode_field: NfStringProperty(name="Gcode Field", default="GCODE") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Database" + _token_prop_name = "token_path" def init(self, context): self._init_exec() @@ -1102,43 +1099,19 @@ class SLICENODE_BaserowSubmit(ExecSliceNode): def draw_buttons(self, context, layout): super().draw_buttons(context, layout) - layout.prop(self, "host") - layout.prop(self, "token_path") - layout.prop(self, "table_id") layout.prop(self, "record_id") - layout.prop(self, "gcode_field") - def evaluate(self, operator=None): - from ...infra.networking.baserow_backend import BaserowBackend - import tempfile + def _prepare_submission(self, operator=None) -> tuple[str, str, dict[str, str]]: import re import json - import os - from pathlib import Path - gcode: str = resolve_input(self.inputs.get("Gcode")) if not gcode: if operator: operator.report({'ERROR'}, "No Gcode to send") - return 'no gcode' + return '', '', {} filename: str = resolve_input(self.inputs.get("Filename")) or "output.gcode" if not filename.endswith(".gcode"): filename += ".gcode" - - token = "" - if self.token_path: - abs_token_path = bpy.path.abspath(self.token_path) - if os.path.exists(abs_token_path): - try: - with open(abs_token_path, 'r', encoding='utf-8') as f: - token = f.read().strip() - except Exception as e: - if operator: operator.report({'ERROR'}, f"Failed to read token file: {str(e)}") - return 'error' - - if not token or not self.table_id or not self.host: - if operator: operator.report({'ERROR'}, "Baserow configuration incomplete (Token File, Table ID and Host required)") - return 'incomplete config' # Parse Metadata from Gcode fields = {} @@ -1170,8 +1143,32 @@ class SLICENODE_BaserowSubmit(ExecSliceNode): weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gcode) if weight_match: fields["Weight per print"] = int(float(weight_match.group(1))) + + return gcode, filename, fields - db = BaserowBackend(self.host, token) + def evaluate(self, operator=None): + import tempfile + import os + from pathlib import Path + + gcode, filename, fields = self._prepare_submission(operator) + if not gcode: return 'error' + + token_path = getattr(self, self._token_prop_name, "") + token = "" + if token_path: + abs_token_path = bpy.path.abspath(token_path) + if os.path.exists(abs_token_path): + try: + with open(abs_token_path, 'r', encoding='utf-8') as f: + token = f.read().strip() + except Exception as e: + if operator: operator.report({'ERROR'}, f"Failed to read token file: {str(e)}") + return 'error' + + if not self._check_config(token): + if operator: operator.report({'ERROR'}, f"{self._db_name} configuration incomplete") + return 'incomplete config' temp_dir = tempfile.TemporaryDirectory() temp_path = Path(temp_dir.name) / filename @@ -1179,27 +1176,97 @@ class SLICENODE_BaserowSubmit(ExecSliceNode): f.write(gcode) try: - if self.record_id: - try: - rid = int(self.record_id) - res = db.upsert_record(self.table_id, rid, fields) - except ValueError: - if operator: operator.report({'ERROR'}, "Record ID must be an integer") - return 'error' - else: - res = db.create_record(self.table_id, fields) - - record_id = int(res['id']) - db.upload_attachment(self.table_id, record_id, self.gcode_field, temp_path) - - if operator: operator.report({'INFO'}, f"Submitted to Baserow (Record: {record_id})") + record_id = self._submit_to_db(token, fields, temp_path) + if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})") return 'ok' except Exception as e: - if operator: operator.report({'ERROR'}, f"Baserow Error: {str(e)}") + if operator: operator.report({'ERROR'}, f"{self._db_name} Error: {str(e)}") return 'error' finally: + if temp_path.exists(): + temp_path.unlink() temp_dir.cleanup() + def _check_config(self, token: str) -> bool: + return False + + def _submit_to_db(self, token: str, fields: dict, temp_path: Path) -> str: + return "" + +@register_class +class SLICENODE_BaserowSubmit(ExecSliceNode_DatabaseSubmit): + bl_label = "Baserow Submit" + button_label = "Submit to Baserow" + + host: NfStringProperty(name="Host", default="https://baserow.io") # pyright: ignore[reportInvalidTypeForm] + token_path: NfStringProperty(name="Token File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] + table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Baserow" + _token_prop_name = "token_path" + + def draw_buttons(self, context, layout): + layout.prop(self, "host") + layout.prop(self, "token_path") + layout.prop(self, "table_id") + super().draw_buttons(context, layout) + + def _check_config(self, token): + return bool(token and self.table_id and self.host) + + def _submit_to_db(self, token, fields, temp_path): + from ...infra.networking.baserow_backend import BaserowBackend + db = BaserowBackend(self.host, token) + + if self.record_id: + try: + rid = int(self.record_id) + res = db.upsert_record(self.table_id, rid, fields) + except ValueError: + raise ValueError("Record ID must be an integer") + else: + res = db.create_record(self.table_id, fields) + + record_id = int(res['id']) + db.upload_attachment(self.table_id, record_id, 'GCODE', temp_path) + return str(record_id) + +@register_class +class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit): + bl_label = "Airtable Submit" + button_label = "Submit to Airtable" + + pat_path: NfStringProperty(name="PAT File", subtype='FILE_PATH', default="") # pyright: ignore[reportInvalidTypeForm] + base_id: NfStringProperty(name="Base ID", default="") # pyright: ignore[reportInvalidTypeForm] + table_id: NfStringProperty(name="Table ID", default="") # pyright: ignore[reportInvalidTypeForm] + + _db_name = "Airtable" + _token_prop_name = "pat_path" + + def draw_buttons(self, context, layout): + layout.prop(self, "pat_path") + layout.prop(self, "base_id") + layout.prop(self, "table_id") + super().draw_buttons(context, layout) + + def _check_config(self, token): + return bool(token and self.base_id and self.table_id) + + def _submit_to_db(self, token, fields, temp_path): + from ...infra.networking.airtable_backend import AirtableInterface, AirtableRecord + db = AirtableInterface(token) + + if self.record_id: + record: AirtableRecord = {"id": self.record_id, "fields": fields} + res = db.update_records(self.base_id, self.table_id, [record]) + record_id = res[0]['id'] + else: + res = db.create_record(self.base_id, self.table_id, fields) + record_id = res['id'] + + db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path) + return str(record_id) + @register_class class SLICENODE_PrusaslicerSlice(ExecSliceNode): bl_label = "PrusaSlicer Slice" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 51a24a1..e4f8a02 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -141,9 +141,9 @@ class SLICENODE_OT_Execute(Operator): return {"FINISHED"} @register_class -class SLICENODE_MT_Menu(Menu): - bl_idname = "SLICENODE_MT_Menu" - bl_label = "SliceNodes" +class SLICENODE_MT_DataInput(Menu): + bl_idname = "SLICENODE_MT_DataInput" + bl_label = "Data & Input" def draw(self, context): layout = self.layout @@ -152,15 +152,23 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat" layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt" layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation" - layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" - layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" + layout.separator() layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" layout.operator("node.add_node", text="Overrides").type = "SLICENODE_InputOverrides" - layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" layout.operator("node.add_node", text="PrusaSlicer Config").type = "SLICENODE_PrusaslicerConf" + layout.separator() + layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" layout.operator("node.add_node", text="Printer List").type = "SLICENODE_PrinterList" layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" - layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" + +@register_class +class SLICENODE_MT_Geometry(Menu): + bl_idname = "SLICENODE_MT_Geometry" + bl_label = "Geometry" + + def draw(self, context): + layout = self.layout + assert layout layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() @@ -168,27 +176,73 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" - layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" + layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" layout.separator() + layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" + layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" + +@register_class +class SLICENODE_MT_Gcode(Menu): + bl_idname = "SLICENODE_MT_Gcode" + bl_label = "Gcode" + + def draw(self, context): + layout = self.layout + assert layout + layout.operator("node.add_node", text="Paths to Gcode").type = "SLICENODE_PathsToGcode" layout.operator("node.add_node", text="Gcode Block").type = "SLICENODE_GcodeBlock" layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper" layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode" - layout.separator() - layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" + +@register_class +class SLICENODE_MT_Joiners(Menu): + bl_idname = "SLICENODE_MT_Joiners" + bl_label = "Joiners" + + def draw(self, context): + layout = self.layout + assert layout layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups" layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" + layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" layout.operator("node.add_node", text="Join Overrides").type = "SLICENODE_JoinOverrides" layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution" - layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" - layout.separator() + +@register_class +class SLICENODE_MT_ActionIO(Menu): + bl_idname = "SLICENODE_MT_ActionIO" + bl_label = "Action & I/O" + + def draw(self, context): + layout = self.layout + assert layout layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock" layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint" + layout.separator() layout.operator("node.add_node", text="Baserow Submit").type = "SLICENODE_BaserowSubmit" + layout.operator("node.add_node", text="Airtable Submit").type = "SLICENODE_AirtableSubmit" + layout.separator() layout.operator("node.add_node", text="Gcode Preview").type = "SLICENODE_GcodeToMesh" layout.operator("node.add_node", text="PrusaSlicer Slice").type = "SLICENODE_PrusaslicerSlice" layout.operator("node.add_node", text="Executor").type = "SLICENODE_Executor" + layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" + +@register_class +class SLICENODE_MT_Menu(Menu): + bl_idname = "SLICENODE_MT_Menu" + bl_label = "SliceNodes" + + def draw(self, context): + layout = self.layout + assert layout + layout.menu("SLICENODE_MT_DataInput") + layout.menu("SLICENODE_MT_Geometry") + layout.menu("SLICENODE_MT_Gcode") + layout.menu("SLICENODE_MT_Joiners") + layout.menu("SLICENODE_MT_ActionIO") + def draw_add_menu(self, context): space = context.space_data diff --git a/doc/NODES.md b/doc/NODES.md index 318ae66..c77fcc6 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -74,6 +74,18 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Header**: Toggles the generation of standard NodeSlicer G-code headers and units setup. - **Load Gcode**: Reads a G-code file from disk. - **Gcode to File**: Writes G-code string to a file on disk. +- **Baserow Submit**: Sends G-code and metadata to a Baserow database. + - **Host**: Baserow host URL. + - **Token File**: Path to a file containing the Baserow API token. + - **Table ID**: The ID of the target Baserow table. + - **Record ID**: Optional ID of an existing record to update. + - **Gcode Field**: The name of the attachment field in Baserow. +- **Airtable Submit**: Sends G-code and metadata to an Airtable base. + - **PAT File**: Path to a file containing the Airtable Personal Access Token (PAT). + - **Base ID**: The ID of the target Airtable base. + - **Table ID**: The ID or name of the target Airtable table. + - **Record ID**: Optional ID of an existing record to update. + - **Gcode Field**: The name of the attachment field in Airtable. - **PrusaSlicer Slice**: Exports the current mesh and configuration to a 3MF file and calls PrusaSlicer to generate G-code. - **Executable**: Path to the PrusaSlicer executable. - **Object**: The Blender object to slice. diff --git a/infra/networking/airtable_backend.py b/infra/networking/airtable_backend.py new file mode 100644 index 0000000..0c3d9e8 --- /dev/null +++ b/infra/networking/airtable_backend.py @@ -0,0 +1,168 @@ +from pathlib import Path +import requests, base64, mimetypes, os, time + +from typing import Callable, TypeAlias, TypeVar, Any +F = TypeVar('F', bound=Callable[..., Any]) + +from typing import TypedDict, Any + +AirtablePurchaseFields: TypeAlias = dict[str, Any] + +class AirtableRecord(TypedDict): + id: str + fields: AirtablePurchaseFields + +class AirtableInterface: + def __init__(self, pat: str, log_base_id: str | None = None, log_table_id: str | None = None) -> None: + self.pat: str = pat + self.log_base_id: str | None = log_base_id + self.log_table_id: str | None = log_table_id + + def fetch(self, base_id: str, table_id: str, view: str|None = None, filterByFormula: str | None =None, fields : list[str] = []) -> dict[Any, Any]: + url = f"https://api.airtable.com/v0/{base_id}/{table_id}" + + headers = {"Authorization": f"Bearer {self.pat}"} + + records = {} + offset = None + + while True: + params = {} + if offset: + params["offset"] = offset + if view: + params["view"] = view + if filterByFormula: + params["filterByFormula"] = filterByFormula + if len(fields): + params["fields[]"] = fields + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + for record in data.get("records", []): + records[record['id']] = record + + offset = data.get("offset") + if not offset: + break + + return records + + def fetch_record_by_id(self, base_id: str, table_name: str, record_id: str): + url = f"https://api.airtable.com/v0/{base_id}/{table_name}/{record_id}" + headers = { + "Authorization": f"Bearer {self.pat}", + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + record = response.json() + return record + + def update_records(self, base_id: str, table_name: str, records: list[AirtableRecord], + typecast: bool=False, returnFieldsByFieldId: bool=False) -> list[dict[Any, Any]]: + url = f"https://api.airtable.com/v0/{base_id}/{table_name}" + headers = { + "Authorization": f"Bearer {self.pat}", + "Content-Type": "application/json" + } + + all_results = [] + + for i in range(0, len(records), 10): + batch = records[i:i+10] + payload = { + "records": batch, + "typecast": typecast, + "returnFieldsByFieldId": returnFieldsByFieldId, + } + + response = requests.patch(url, headers=headers, json=payload) + response.raise_for_status() + + result = response.json() + all_results += result['records'] + + time.sleep(1) + + return all_results + + def upsert_records(self, base_id: str, table_name: str, records: list[AirtablePurchaseFields], fieldsToMergeOn: list[str] | None = None, + typecast: bool=False, returnFieldsByFieldId: bool=False) -> list[dict[Any, Any]]: + url = f"https://api.airtable.com/v0/{base_id}/{table_name}" + headers = { + "Authorization": f"Bearer {self.pat}", + "Content-Type": "application/json" + } + + all_results = [] + + for i in range(0, len(records), 10): + batch = records[i:i+10] + payload = { + "records": [{'fields': r} for r in batch], + "typecast": typecast, + "returnFieldsByFieldId": returnFieldsByFieldId, + } + if fieldsToMergeOn: payload['performUpsert'] = { + "fieldsToMergeOn": fieldsToMergeOn + } + response = requests.patch(url, headers=headers, json=payload) + + if response.status_code == 422: + try: + err = response.json() + print("Airtable error.type:", err.get("error", {}).get("type")) + print("Airtable error.message:", err.get("error", {}).get("message")) + except ValueError: + print("Airtable error body:", response.text) + + response.raise_for_status() + + result = response.json() + all_results += result['records'] + + time.sleep(1) + + return all_results + + def create_record(self, base_id: str, table_name: str, fields: AirtablePurchaseFields): + url = f"https://api.airtable.com/v0/{base_id}/{table_name}" + headers = { + "Authorization": f"Bearer {self.pat}", + "Content-Type": "application/json" + } + payload = {"fields": fields} + + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + + record = response.json() + return record + + def upload_attachment(self, base_id: str, record_id: str, field_id: str, filepath: str | Path): + url = f"https://content.airtable.com/v0/{base_id}/{record_id}/{field_id}/uploadAttachment" + headers = { + "Authorization": f"Bearer {self.pat}", + "Content-Type": "application/json" + } + with open(filepath, "rb") as f: + file_data = f.read() + content_type, _ = mimetypes.guess_type(filepath) + if content_type is None: + content_type = "application/octet-stream" + encoded_file = base64.b64encode(file_data).decode("utf-8") + + payload = { + "contentType": content_type, + "file": encoded_file, + "filename": os.path.basename(filepath), + } + + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + + return response.json() \ No newline at end of file