airtable upload
This commit is contained in:
parent
0c4d4398e1
commit
e155b26171
5 changed files with 364 additions and 63 deletions
|
|
@ -10,6 +10,7 @@ from typing import Any, TypeAlias
|
||||||
import bpy
|
import bpy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from mathutils import Euler
|
from mathutils import Euler
|
||||||
|
|
||||||
from .bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed
|
from .bpy_ns_types import NfIntProperty, NfStringProperty, NfEnumProperty, NfBoolProperty, notify_tree_changed
|
||||||
|
|
||||||
from ...registry import register_class
|
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.blender.blender_slice_nodes_generic import SliceNode, resolve_input
|
||||||
from ...infra.prusaslicer_profiles.cache import LocalCache
|
from ...infra.prusaslicer_profiles.cache import LocalCache
|
||||||
from ...infra.prusaslicer_profiles.models import Profile
|
from ...infra.prusaslicer_profiles.models import Profile
|
||||||
|
from ...infra.networking.airtable_backend import AirtableRecord
|
||||||
from ...infra._3mf_writer._3mf import prepare_3mf
|
from ...infra._3mf_writer._3mf import prepare_3mf
|
||||||
|
|
||||||
class SliceIntrinsics:
|
class SliceIntrinsics:
|
||||||
|
|
@ -1084,16 +1086,11 @@ class SLICENODE_StartPrint(ExecSliceNode):
|
||||||
|
|
||||||
return 'ok'
|
return 'ok'
|
||||||
|
|
||||||
@register_class
|
class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
|
||||||
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]
|
|
||||||
record_id: NfStringProperty(name="Record ID", default="") # pyright: ignore[reportInvalidTypeForm]
|
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):
|
def init(self, context):
|
||||||
self._init_exec()
|
self._init_exec()
|
||||||
|
|
@ -1102,43 +1099,19 @@ class SLICENODE_BaserowSubmit(ExecSliceNode):
|
||||||
|
|
||||||
def draw_buttons(self, context, layout):
|
def draw_buttons(self, context, layout):
|
||||||
super().draw_buttons(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, "record_id")
|
||||||
layout.prop(self, "gcode_field")
|
|
||||||
|
|
||||||
def evaluate(self, operator=None):
|
def _prepare_submission(self, operator=None) -> tuple[str, str, dict[str, str]]:
|
||||||
from ...infra.networking.baserow_backend import BaserowBackend
|
|
||||||
import tempfile
|
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
gcode: str = resolve_input(self.inputs.get("Gcode"))
|
gcode: str = resolve_input(self.inputs.get("Gcode"))
|
||||||
if not gcode:
|
if not gcode:
|
||||||
if operator: operator.report({'ERROR'}, "No Gcode to send")
|
if operator: operator.report({'ERROR'}, "No Gcode to send")
|
||||||
return 'no gcode'
|
return '', '', {}
|
||||||
|
|
||||||
filename: str = resolve_input(self.inputs.get("Filename")) or "output.gcode"
|
filename: str = resolve_input(self.inputs.get("Filename")) or "output.gcode"
|
||||||
if not filename.endswith(".gcode"):
|
if not filename.endswith(".gcode"):
|
||||||
filename += ".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
|
# Parse Metadata from Gcode
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
@ -1170,8 +1143,32 @@ class SLICENODE_BaserowSubmit(ExecSliceNode):
|
||||||
weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gcode)
|
weight_match = re.search(r"; total filament used \[g\] = ([\d\.]+)", gcode)
|
||||||
if weight_match:
|
if weight_match:
|
||||||
fields["Weight per print"] = int(float(weight_match.group(1)))
|
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_dir = tempfile.TemporaryDirectory()
|
||||||
temp_path = Path(temp_dir.name) / filename
|
temp_path = Path(temp_dir.name) / filename
|
||||||
|
|
@ -1179,27 +1176,97 @@ class SLICENODE_BaserowSubmit(ExecSliceNode):
|
||||||
f.write(gcode)
|
f.write(gcode)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.record_id:
|
record_id = self._submit_to_db(token, fields, temp_path)
|
||||||
try:
|
if operator: operator.report({'INFO'}, f"Submitted to {self._db_name} (Record: {record_id})")
|
||||||
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})")
|
|
||||||
return 'ok'
|
return 'ok'
|
||||||
except Exception as e:
|
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'
|
return 'error'
|
||||||
finally:
|
finally:
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
temp_dir.cleanup()
|
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
|
@register_class
|
||||||
class SLICENODE_PrusaslicerSlice(ExecSliceNode):
|
class SLICENODE_PrusaslicerSlice(ExecSliceNode):
|
||||||
bl_label = "PrusaSlicer Slice"
|
bl_label = "PrusaSlicer Slice"
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,9 @@ class SLICENODE_OT_Execute(Operator):
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
@register_class
|
@register_class
|
||||||
class SLICENODE_MT_Menu(Menu):
|
class SLICENODE_MT_DataInput(Menu):
|
||||||
bl_idname = "SLICENODE_MT_Menu"
|
bl_idname = "SLICENODE_MT_DataInput"
|
||||||
bl_label = "SliceNodes"
|
bl_label = "Data & Input"
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
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="Float").type = "SLICENODE_InputFloat"
|
||||||
layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt"
|
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="Rotation").type = "SLICENODE_InputVectorRotation"
|
||||||
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
|
layout.separator()
|
||||||
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
|
|
||||||
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
|
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="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.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 List").type = "SLICENODE_PrinterList"
|
||||||
layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics"
|
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 Mesh").type = "SLICENODE_GetMesh"
|
||||||
layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
|
layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
|
||||||
layout.separator()
|
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").type = "SLICENODE_BufferShells"
|
||||||
layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple"
|
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="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.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 Block").type = "SLICENODE_GcodeBlock"
|
||||||
layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper"
|
layout.operator("node.add_node", text="Gcode Helper").type = "SLICENODE_GcodeHelper"
|
||||||
layout.operator("node.add_node", text="Load Gcode").type = "SLICENODE_LoadGcode"
|
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 Parts").type = "SLICENODE_JoinMesh"
|
||||||
layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups"
|
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 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 Overrides").type = "SLICENODE_JoinOverrides"
|
||||||
layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution"
|
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 File").type = "SLICENODE_GcodeToFile"
|
||||||
layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock"
|
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.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="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="Gcode Preview").type = "SLICENODE_GcodeToMesh"
|
||||||
layout.operator("node.add_node", text="PrusaSlicer Slice").type = "SLICENODE_PrusaslicerSlice"
|
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="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):
|
def draw_add_menu(self, context):
|
||||||
space = context.space_data
|
space = context.space_data
|
||||||
|
|
|
||||||
12
doc/NODES.md
12
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.
|
- **Header**: Toggles the generation of standard NodeSlicer G-code headers and units setup.
|
||||||
- **Load Gcode**: Reads a G-code file from disk.
|
- **Load Gcode**: Reads a G-code file from disk.
|
||||||
- **Gcode to File**: Writes G-code string to a file on 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.
|
- **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.
|
- **Executable**: Path to the PrusaSlicer executable.
|
||||||
- **Object**: The Blender object to slice.
|
- **Object**: The Blender object to slice.
|
||||||
|
|
|
||||||
168
infra/networking/airtable_backend.py
Normal file
168
infra/networking/airtable_backend.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue