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
This commit is contained in:
maziggy
2026-01-25 06:47:55 +01:00
parent 2669eabe2b
commit 6bc1ce8693
12 changed files with 164 additions and 86 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>