Przeglądaj źródła

feat: implement automatic 3D model import from Thingiverse and Printables

unknown 2 miesięcy temu
rodzic
commit
6b47d961be

+ 2 - 1
backend/main.py

@@ -21,7 +21,7 @@ from services.chat_manager import manager
 import locales
 import locales
 import config
 import config
 import db
 import db
-from routers import auth, orders, catalog, portfolio, files, chat, blog, admin, contact, warehouse
+from routers import auth, orders, catalog, portfolio, files, chat, blog, admin, contact, warehouse, importer
 
 
 app = FastAPI(title="Radionica 3D API")
 app = FastAPI(title="Radionica 3D API")
 
 
@@ -85,6 +85,7 @@ app.include_router(blog.router)
 app.include_router(admin.router)
 app.include_router(admin.router)
 app.include_router(contact.router)
 app.include_router(contact.router)
 app.include_router(warehouse.router)
 app.include_router(warehouse.router)
+app.include_router(importer.router)
 
 
 # WebSocket Global Handler (Centralized to handle various proxy prefixes)
 # WebSocket Global Handler (Centralized to handle various proxy prefixes)
 @app.websocket("/global")
 @app.websocket("/global")

+ 30 - 0
backend/routers/importer.py

@@ -0,0 +1,30 @@
+from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
+from pydantic import BaseModel
+from services.link_importer import LinkImporter
+from dependencies import get_current_user
+import os
+
+router = APIRouter(prefix="/importer", tags=["importer"])
+
+class ImportRequest(BaseModel):
+    url: str
+
+@router.post("/url")
+async def import_from_url(request: ImportRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)):
+    # Note: we allow guests to upload, but here we require login for safety 
+    # to prevent anonymous server-side request forgery (SSRF).
+    if not user:
+        raise HTTPException(status_code=401, detail="Please login to use link import feature")
+
+    try:
+        upload_dir = "uploads" # Shared with other routers
+        if not os.path.exists(upload_dir):
+            os.makedirs(upload_dir, exist_ok=True)
+            
+        files = LinkImporter.download_and_extract(request.url, upload_dir, background_tasks)
+        return {"status": "success", "files": files}
+    except ValueError as ve:
+        raise HTTPException(status_code=400, detail=str(ve))
+    except Exception as e:
+        print(f"IMPORT ERROR: {e}")
+        raise HTTPException(status_code=500, detail=f"Failed to import files: {str(e)}")

+ 107 - 0
backend/services/link_importer.py

@@ -0,0 +1,107 @@
+import re
+import requests
+import os
+import zipfile
+import io
+import uuid
+from typing import List, Dict
+
+# Standard headers to avoid bot detection
+HEADERS = {
+    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
+}
+
+class LinkImporter:
+    @staticmethod
+    def extract_id(url: str):
+        # Thingiverse: thingiverse.com/thing:12345 or thingiverse.com/thing:12345/files
+        thing_match = re.search(r"thingiverse\.com/thing:(\d+)", url)
+        if thing_match:
+            return "thingiverse", thing_match.group(1)
+        
+        # Printables: printables.com/model/12345-name or printables.com/en/model/12345
+        printables_match = re.search(r"printables\.com/(?:[a-z]{2}/)?model/(\d+)", url)
+        if printables_match:
+            return "printables", printables_match.group(1)
+            
+        return None, None
+
+    @staticmethod
+    def download_and_extract(url: str, upload_dir: str, background_tasks=None) -> List[Dict]:
+        service, item_id = LinkImporter.extract_id(url)
+        if not service:
+            raise ValueError("Unsupported URL format. Please use Thingiverse or Printables link.")
+
+        download_url = ""
+        if service == "thingiverse":
+            download_url = f"https://www.thingiverse.com/thing:{item_id}/zip"
+        elif service == "printables":
+            download_url = f"https://www.printables.com/model/{item_id}/files/zip"
+
+        print(f"DEBUG: Importing from {service} ID {item_id}. URL: {download_url}")
+        
+        try:
+            response = requests.get(download_url, headers=HEADERS, stream=True, timeout=60)
+            response.raise_for_status()
+        except Exception as e:
+            raise Exception(f"Failed to download from {service}: {e}")
+
+        import db
+        import hashlib
+        from services import order_processing
+
+        # Process the ZIP in memory
+        imported_files = []
+        with zipfile.ZipFile(io.BytesIO(response.content)) as z:
+            for file_info in z.infolist():
+                filename = os.path.basename(file_info.filename)
+                if not filename or filename.startswith('.') or filename.startswith('__MACOSX'):
+                    continue
+                
+                ext = os.path.splitext(filename)[1].lower()
+                if ext in ['.stl', '.obj', '.step', '.stp', '.3mf']:
+                    unique_name = f"{uuid.uuid4().hex}{ext}"
+                    save_path = os.path.join(upload_dir, unique_name)
+                    db_file_path = f"uploads/{unique_name}"
+                    
+                    # Read content to memory to hash it
+                    content = z.read(file_info.filename)
+                    with open(save_path, "wb") as target:
+                        target.write(content)
+                    
+                    file_hash = hashlib.sha256(content).hexdigest()
+                    file_size = len(content)
+                    
+                    # Check cache for filament/time
+                    filament_g = None
+                    print_time = None
+                    cached = db.execute_query(
+                        "SELECT filament_g, print_time FROM order_files WHERE file_hash = %s AND print_time IS NOT NULL LIMIT 1",
+                        (file_hash,)
+                    )
+                    if cached:
+                        filament_g = cached[0]['filament_g']
+                        print_time = cached[0]['print_time']
+                    
+                    # Insert into DB
+                    query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (NULL, %s, %s, %s, 1, %s, %s, %s, NULL)"
+                    f_id = db.execute_commit(query, (filename, db_file_path, file_size, file_hash, print_time, filament_g))
+                    
+                    # Trigger background processing (previews, etc.)
+                    if background_tasks:
+                        background_tasks.add_task(order_processing.process_single_file_async, f_id)
+                    
+                    imported_files.append({
+                        "id": f_id,
+                        "filename": filename,
+                        "size": file_size,
+                        "print_time": print_time,
+                        "filament_g": filament_g,
+                        "status": "success"
+                    })
+        
+        if not imported_files:
+            raise Exception("No valid 3D models found in the downloaded archive.")
+            
+        return imported_files

+ 55 - 5
src/components/ModelUploadSection.vue

@@ -156,8 +156,21 @@
             <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
             <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
               {{ t("upload.modelLink") }}
               {{ t("upload.modelLink") }}
             </label>
             </label>
-            <input v-model="modelLink" type="url" :placeholder="t('upload.modelLinkPlaceholder')"
-              class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
+            <div class="relative group">
+              <input v-model="modelLink" type="url" :placeholder="t('upload.modelLinkPlaceholder')"
+                class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 pr-12 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
+              <button 
+                v-if="modelLink.includes('thingiverse.com') || modelLink.includes('printables.com')"
+                type="button"
+                @click="handleImportUrl"
+                :disabled="isImporting"
+                class="absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-xl bg-primary text-white hover:bg-primary/90 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
+                :title="t('common.import')"
+              >
+                <Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
+                <LinkIcon v-else class="w-4 h-4" />
+              </button>
+            </div>
           </div>
           </div>
         </div>
         </div>
 
 
@@ -323,9 +336,9 @@ import { Upload, FileBox, X, Check, Link as LinkIcon, MapPin, User, Phone, Mail,
 import Button from "./ui/button.vue";
 import Button from "./ui/button.vue";
 import { defineAsyncComponent } from "vue";
 import { defineAsyncComponent } from "vue";
 const StlViewer = defineAsyncComponent(() => import("@/components/StlViewer.vue"));
 const StlViewer = defineAsyncComponent(() => import("@/components/StlViewer.vue"));
-import { submitOrder, getCurrentUser, getMaterials, getPriceEstimate, uploadFilesToServer } from "@/lib/api";
+import { submitOrder, getCurrentUser, getMaterials, getPriceEstimate, uploadFilesToServer, importFromUrl } from "@/lib/api";
 
 
-interface UploadedFile { id: string; dbId?: number; name: string; size: number; type: string; file: File; quantity: number; basePrice?: number; isUploading?: boolean; printTime?: string; filamentG?: number; }
+interface UploadedFile { id: string; dbId?: number; name: string; size: number; type: string; file?: File; quantity: number; basePrice?: number; isUploading?: boolean; printTime?: string; filamentG?: number; }
 
 
 // --- CONFIGURATION ---
 // --- CONFIGURATION ---
 // Set to false to hide the hardcore slicing data (time and weight) from the end-user
 // Set to false to hide the hardcore slicing data (time and weight) from the end-user
@@ -335,6 +348,7 @@ const { t, locale } = useI18n();
 const files = ref<UploadedFile[]>([]);
 const files = ref<UploadedFile[]>([]);
 const isDragging = ref(false);
 const isDragging = ref(false);
 const isSubmitting = ref(false);
 const isSubmitting = ref(false);
+const isImporting = ref(false);
 const modelLink = ref("");
 const modelLink = ref("");
 const address = ref("");
 const address = ref("");
 const firstName = ref("");
 const firstName = ref("");
@@ -461,7 +475,43 @@ async function addFiles(rawFiles: File[]) {
   }
   }
 }
 }
 function handleDrop(e: DragEvent) { isDragging.value = false; addFiles(Array.from(e.dataTransfer?.files ?? [])); }
 function handleDrop(e: DragEvent) { isDragging.value = false; addFiles(Array.from(e.dataTransfer?.files ?? [])); }
-function handleFileSelect(e: Event) { addFiles(Array.from((e.target as HTMLInputElement).files ?? [])); }
+async function handleImportUrl() {
+  if (!modelLink.value || isImporting.value) return;
+  
+  const token = localStorage.getItem("token");
+  if (!token) {
+    toast.error(t("auth.loginRequired"));
+    return;
+  }
+
+  isImporting.value = true;
+  try {
+    const res = await importFromUrl(modelLink.value);
+    if (res.status === "success" && res.files) {
+      for (const f of res.files) {
+        files.value.push({
+          id: Math.random().toString(36).substr(2, 9),
+          dbId: f.id,
+          name: f.filename,
+          size: f.size,
+          type: "model/stl", // Assumption
+          quantity: 1,
+          printTime: f.print_time,
+          filamentG: f.filament_g,
+          isUploading: false
+        });
+      }
+      toast.success(t("common.success"));
+    }
+  } catch (e: any) {
+    console.error("Import failed:", e);
+    toast.error(e.message || "Failed to import models. Check URL or try again later.");
+  } finally {
+    isImporting.value = false;
+  }
+}
+
+async function handleFileSelect(e: Event) { addFiles(Array.from((e.target as HTMLInputElement).files ?? [])); }
 function removeFile(id: string) { files.value = files.value.filter(f => f.id !== id); }
 function removeFile(id: string) { files.value = files.value.filter(f => f.id !== id); }
 function formatFileSize(bytes: number) {
 function formatFileSize(bytes: number) {
   if (bytes < 1024) return bytes + " B";
   if (bytes < 1024) return bytes + " B";

+ 14 - 0
src/lib/api.ts

@@ -55,6 +55,20 @@ export const uploadFilesToServer = async (data: FormData) => {
   return response.json();
   return response.json();
 };
 };
 
 
+export const importFromUrl = async (url: string) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/importer/url`, {
+    method: "POST",
+    headers: { 
+      "Content-Type": "application/json",
+      "Authorization": `Bearer ${token}`
+    },
+    body: JSON.stringify({ url }),
+  });
+  if (!response.ok) throw new Error(await getErrorMessage(response, "Failed to import from URL"));
+  return response.json();
+};
+
 export const submitOrder = async (orderData: FormData) => {
 export const submitOrder = async (orderData: FormData) => {
   const token = localStorage.getItem("token");
   const token = localStorage.getItem("token");
   const headers: Record<string, string> = {};
   const headers: Record<string, string> = {};