Browse Source

Add external link field for archives (Issue #151)

Allow users to manually set external URLs for archives from Printables,
Thingiverse, or other sources. The Globe button now opens the external
link when set, falling back to auto-detected MakerWorld URL.

- Add external_url field to PrintArchive model with migration
- Add input field to archive edit modal
- Update Globe button: external_url > makerworld_url > disabled
- Include external_url in backup/restore
- Add API test for external_url update
- Update docs (changelog, wiki, website)

Closes #151
maziggy 4 months ago
parent
commit
6bc1ce8693

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6] - 2026-01-24
 
 ### New Features
+- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
+  - Link archives to Printables, Thingiverse, or any other URL
+  - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
+  - Edit via archive edit modal
+  - Included in backup/restore
 - **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
   - Configure per-printer external camera URL and type in Settings → Camera
   - Live streaming uses external camera when enabled

+ 1 - 0
backend/app/api/routes/archives.py

@@ -84,6 +84,7 @@ def archive_to_response(
         "extra_data": archive.extra_data,
         "makerworld_url": archive.makerworld_url,
         "designer": archive.designer,
+        "external_url": archive.external_url,
         "is_favorite": archive.is_favorite,
         "tags": archive.tags,
         "notes": archive.notes,

+ 2 - 0
backend/app/api/routes/settings.py

@@ -616,6 +616,7 @@ async def export_backup(
                 "completed_at": a.completed_at.isoformat() if a.completed_at else None,
                 "makerworld_url": a.makerworld_url,
                 "designer": a.designer,
+                "external_url": a.external_url,
                 "is_favorite": a.is_favorite,
                 "tags": a.tags,
                 "notes": a.notes,
@@ -1464,6 +1465,7 @@ async def import_backup(
                     status=archive_data.get("status", "completed"),
                     makerworld_url=archive_data.get("makerworld_url"),
                     designer=archive_data.get("designer"),
+                    external_url=archive_data.get("external_url"),
                     is_favorite=archive_data.get("is_favorite", False),
                     tags=archive_data.get("tags"),
                     notes=archive_data.get("notes"),

+ 6 - 0
backend/app/core/database.py

@@ -689,6 +689,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
+    try:
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 4 - 1
backend/app/models/archive.py

@@ -43,10 +43,13 @@ class PrintArchive(Base):
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 
-    # MakerWorld info
+    # MakerWorld info (auto-extracted from 3MF)
     makerworld_url: Mapped[str | None] = mapped_column(String(500))
     designer: Mapped[str | None] = mapped_column(String(255))
 
+    # User-defined external link (Printables, Thingiverse, etc.)
+    external_url: Mapped[str | None] = mapped_column(String(500))
+
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
     tags: Mapped[str | None] = mapped_column(Text)

+ 2 - 0
backend/app/schemas/archive.py

@@ -11,6 +11,7 @@ class ArchiveBase(BaseModel):
     cost: float | None = None
     failure_reason: str | None = None
     quantity: int | None = None  # Number of items printed
+    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
 
 
 class ArchiveUpdate(ArchiveBase):
@@ -70,6 +71,7 @@ class ArchiveResponse(BaseModel):
 
     makerworld_url: str | None
     designer: str | None
+    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
 
     is_favorite: bool
     tags: str | None

+ 22 - 0
backend/tests/integration/test_archives_api.py

@@ -146,6 +146,28 @@ class TestArchivesAPI:
         assert response.status_code == 200
         assert response.json()["is_favorite"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_external_url(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive external_url can be updated."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["external_url"] == "https://printables.com/model/12345"
+
+        # Verify it can be cleared
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
+
+        assert response.status_code == 200
+        assert response.json()["external_url"] is None
+
     # ========================================================================
     # Delete endpoints
     # ========================================================================

+ 2 - 0
frontend/src/api/client.ts

@@ -259,6 +259,7 @@ export interface Archive {
   extra_data: Record<string, unknown> | null;
   makerworld_url: string | null;
   designer: string | null;
+  external_url: string | null;
   is_favorite: boolean;
   tags: string | null;
   notes: string | null;
@@ -1693,6 +1694,7 @@ export const api = {
     failure_reason?: string | null;
     status?: string;
     quantity?: number;
+    external_url?: string | null;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',

+ 21 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash } from 'lucide-react';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
@@ -51,6 +51,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
+  const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
   const [showTagSuggestions, setShowTagSuggestions] = useState(false);
   const tagInputRef = useRef<HTMLInputElement>(null);
@@ -155,6 +156,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       notes: notes || undefined,
       tags: tags || undefined,
       quantity: quantity,
+      external_url: externalUrl || null,
     };
 
     // Only include status if changed
@@ -275,6 +277,24 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             />
           </div>
 
+          {/* External Link */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Link className="w-4 h-4 inline mr-1" />
+              External Link
+            </label>
+            <input
+              type="url"
+              value={externalUrl}
+              onChange={(e) => setExternalUrl(e.target.value)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              placeholder="https://printables.com/model/..."
+            />
+            <p className="text-xs text-bambu-gray mt-1">
+              Link to Printables, Thingiverse, or other source
+            </p>
+          </div>
+
           {/* Tags */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">Tags</label>

+ 28 - 13
frontend/src/pages/ArchivesPage.tsx

@@ -293,10 +293,13 @@ function ArchiveCard({
       },
     ]),
     {
-      label: 'View on MakerWorld',
+      label: archive.external_url ? 'External Link' : 'View on MakerWorld',
       icon: <Globe className="w-4 h-4" />,
-      onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
-      disabled: !archive.makerworld_url,
+      onClick: () => {
+        const url = archive.external_url || archive.makerworld_url;
+        if (url) window.open(url, '_blank');
+      },
+      disabled: !archive.external_url && !archive.makerworld_url,
     },
     { label: '', divider: true, onClick: () => {} },
     {
@@ -812,11 +815,20 @@ function ArchiveCard({
             variant="secondary"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
-            onClick={() => archive.makerworld_url && window.open(archive.makerworld_url, '_blank')}
-            disabled={!archive.makerworld_url}
-            title={archive.makerworld_url ? `MakerWorld: ${archive.designer || 'View project'}` : 'Not from MakerWorld'}
+            onClick={() => {
+              const url = archive.external_url || archive.makerworld_url;
+              if (url) window.open(url, '_blank');
+            }}
+            disabled={!archive.external_url && !archive.makerworld_url}
+            title={
+              archive.external_url
+                ? 'External Link'
+                : archive.makerworld_url
+                  ? `MakerWorld: ${archive.designer || 'View project'}`
+                  : 'No external link'
+            }
           >
-            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.makerworld_url ? 'opacity-20' : ''}`} />
+            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
           </Button>
           <Button
             variant="secondary"
@@ -1283,10 +1295,13 @@ function ArchiveListRow({
       },
     ]),
     {
-      label: 'View on MakerWorld',
+      label: archive.external_url ? 'External Link' : 'View on MakerWorld',
       icon: <Globe className="w-4 h-4" />,
-      onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
-      disabled: !archive.makerworld_url,
+      onClick: () => {
+        const url = archive.external_url || archive.makerworld_url;
+        if (url) window.open(url, '_blank');
+      },
+      disabled: !archive.external_url && !archive.makerworld_url,
     },
     { label: '', divider: true, onClick: () => {} },
     {
@@ -1556,12 +1571,12 @@ function ArchiveListRow({
           >
             <ExternalLink className="w-4 h-4" />
           </Button>
-          {archive.makerworld_url && (
+          {(archive.external_url || archive.makerworld_url) && (
             <Button
               variant="ghost"
               size="sm"
-              onClick={() => window.open(archive.makerworld_url!, '_blank')}
-              title="MakerWorld"
+              onClick={() => window.open((archive.external_url || archive.makerworld_url)!, '_blank')}
+              title={archive.external_url ? 'External Link' : 'MakerWorld'}
             >
               <Globe className="w-4 h-4" />
             </Button>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BQzscOA-.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Du4KZbJd.js"></script>
+    <script type="module" crossorigin src="/assets/index-BQzscOA-.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff