diff --git a/backend/src/dna/models/entity.py b/backend/src/dna/models/entity.py index 24a98e5f..8d989503 100644 --- a/backend/src/dna/models/entity.py +++ b/backend/src/dna/models/entity.py @@ -174,6 +174,10 @@ class Version(EntityBase): ) task: Optional["Task"] = Field(default=None, description="Associated task") notes: list["Note"] = Field(default_factory=list, description="Associated notes") + prodtrack_detail_url: Optional[str] = Field( + default=None, + description="Web UI URL for this version in the production tracking system", + ) @field_validator("notes", mode="before") @classmethod diff --git a/backend/src/dna/models/user_settings.py b/backend/src/dna/models/user_settings.py index 79397404..938ba294 100644 --- a/backend/src/dna/models/user_settings.py +++ b/backend/src/dna/models/user_settings.py @@ -23,6 +23,11 @@ class UserSettingsUpdate(BaseModel): default=None, description="Regenerate AI note when transcript segments are updated", ) + sync_prodtrack_tab_on_version_change: Optional[bool] = Field( + default=None, + description="When true, DNA tells the browser extension to open the PT " + "version page whenever the selected version changes", + ) class UserSettings(BaseModel): @@ -35,5 +40,6 @@ class UserSettings(BaseModel): note_prompt: str = "" regenerate_on_version_change: bool = False regenerate_on_transcript_update: bool = False + sync_prodtrack_tab_on_version_change: bool = True updated_at: datetime created_at: datetime diff --git a/backend/src/dna/models/user_settings_response.py b/backend/src/dna/models/user_settings_response.py index 937c61d3..71ce20e6 100644 --- a/backend/src/dna/models/user_settings_response.py +++ b/backend/src/dna/models/user_settings_response.py @@ -16,5 +16,6 @@ class UserSettingsResponse(BaseModel): default_note_prompt: str = "" regenerate_on_version_change: bool = False regenerate_on_transcript_update: bool = False + sync_prodtrack_tab_on_version_change: bool = True updated_at: datetime created_at: datetime diff --git a/backend/src/dna/prodtrack_providers/mock_provider.py b/backend/src/dna/prodtrack_providers/mock_provider.py index bb72cb4d..07d9eb18 100644 --- a/backend/src/dna/prodtrack_providers/mock_provider.py +++ b/backend/src/dna/prodtrack_providers/mock_provider.py @@ -159,6 +159,9 @@ def _version_from_row( entity=entity, task=task, notes=notes or [], + prodtrack_detail_url=( + f"https://mock-shotgrid.example.com/detail/Version/{row['id']}" + ), ) def _playlist_from_row( diff --git a/backend/src/dna/prodtrack_providers/shotgrid.py b/backend/src/dna/prodtrack_providers/shotgrid.py index 6af9b789..447384ff 100644 --- a/backend/src/dna/prodtrack_providers/shotgrid.py +++ b/backend/src/dna/prodtrack_providers/shotgrid.py @@ -2,7 +2,7 @@ import contextlib import os -from typing import Any, Optional +from typing import Any, Optional, cast from shotgun_api3 import Shotgun @@ -305,9 +305,15 @@ def get_entity( if not sg_entity: raise ValueError(f"Entity not found: {entity_type} {entity_id}") - return self._convert_sg_entity_to_dna_entity( + entity = self._convert_sg_entity_to_dna_entity( sg_entity, entity_mapping, entity_type, resolve_links=resolve_links ) + if entity_type == "version": + version = cast(Version, entity) + base = (self.url or "").rstrip("/") + if base: + version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}" + return entity def _resolve_linked_field(self, data): """Resolve linked entity data by fetching the full entity.""" @@ -775,6 +781,10 @@ def get_versions_for_playlist(self, playlist_id: int) -> list[Version]: if version.id in notes_by_version_id: version.notes = notes_by_version_id[version.id] + base = (self.url or "").rstrip("/") + if base: + version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}" + versions.append(version) return versions diff --git a/backend/src/dna/storage_providers/mongodb.py b/backend/src/dna/storage_providers/mongodb.py index af0fe4e3..71107ac3 100644 --- a/backend/src/dna/storage_providers/mongodb.py +++ b/backend/src/dna/storage_providers/mongodb.py @@ -285,11 +285,16 @@ async def upsert_user_settings( """Create or update user settings.""" now = datetime.now(timezone.utc) query = {"user_email": user_email} - update_fields = {k: v for k, v in data.model_dump().items() if v is not None} + update_fields = { + k: v + for k, v in data.model_dump(exclude_unset=True).items() + if v is not None + } defaults = { "note_prompt": "", "regenerate_on_version_change": False, "regenerate_on_transcript_update": False, + "sync_prodtrack_tab_on_version_change": True, } set_on_insert = { "created_at": now, diff --git a/backend/src/main.py b/backend/src/main.py index 3e03dd91..8249789c 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1201,6 +1201,9 @@ def _user_settings_to_response(settings: UserSettings) -> UserSettingsResponse: default_note_prompt=get_default_note_prompt(), regenerate_on_version_change=settings.regenerate_on_version_change, regenerate_on_transcript_update=settings.regenerate_on_transcript_update, + sync_prodtrack_tab_on_version_change=( + settings.sync_prodtrack_tab_on_version_change + ), updated_at=settings.updated_at, created_at=settings.created_at, ) @@ -1218,6 +1221,7 @@ def _empty_user_settings_response(user_email: str) -> UserSettingsResponse: default_note_prompt=default, regenerate_on_version_change=False, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=True, updated_at=now, created_at=now, ) diff --git a/backend/tests/providers/test_mock_provider.py b/backend/tests/providers/test_mock_provider.py index a5777783..b37bded2 100644 --- a/backend/tests/providers/test_mock_provider.py +++ b/backend/tests/providers/test_mock_provider.py @@ -168,6 +168,9 @@ def test_get_entity_note_with_links(mock_provider): def test_get_entity_version(mock_provider): version = mock_provider.get_entity("version", 300, resolve_links=True) assert version.id == 300 + assert version.prodtrack_detail_url == ( + "https://mock-shotgrid.example.com/detail/Version/300" + ) assert version.name == "v_001" assert version.status == "rev" assert version.task is not None @@ -441,6 +444,9 @@ def test_get_versions_for_playlist(mock_provider): versions = mock_provider.get_versions_for_playlist(400) assert len(versions) == 1 assert versions[0].id == 300 + assert versions[0].prodtrack_detail_url == ( + "https://mock-shotgrid.example.com/detail/Version/300" + ) assert versions[0].task is not None diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 0e5cc902..e816e216 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -944,6 +944,7 @@ def test_generate_note_returns_200( note_prompt="Test prompt {{ transcript }}", regenerate_on_version_change=False, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=False, updated_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc), ) diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index b1dcfb80..30f7f5a9 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -22,6 +22,7 @@ def test_user_settings_update_defaults(self): assert update.note_prompt is None assert update.regenerate_on_version_change is None assert update.regenerate_on_transcript_update is None + assert update.sync_prodtrack_tab_on_version_change is None def test_user_settings_update_with_values(self): """Test UserSettingsUpdate with values.""" @@ -43,6 +44,7 @@ def test_user_settings_full_model(self): note_prompt="My custom prompt", regenerate_on_version_change=True, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=True, updated_at=now, created_at=now, ) @@ -51,6 +53,7 @@ def test_user_settings_full_model(self): assert settings.note_prompt == "My custom prompt" assert settings.regenerate_on_version_change is True assert settings.regenerate_on_transcript_update is False + assert settings.sync_prodtrack_tab_on_version_change is True def test_user_settings_defaults(self): """Test UserSettings default values.""" @@ -64,6 +67,7 @@ def test_user_settings_defaults(self): assert settings.note_prompt == "" assert settings.regenerate_on_version_change is False assert settings.regenerate_on_transcript_update is False + assert settings.sync_prodtrack_tab_on_version_change is True class TestUserSettingsEndpoints: @@ -100,6 +104,7 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client) assert data["default_note_prompt"] == get_default_note_prompt() assert data["regenerate_on_version_change"] is True assert data["regenerate_on_transcript_update"] is False + assert data["sync_prodtrack_tab_on_version_change"] is True mock_storage_provider.get_user_settings.assert_called_once_with( "test@example.com" ) @@ -339,6 +344,7 @@ async def test_get_user_settings_returns_settings( "note_prompt": "Custom prompt", "regenerate_on_version_change": True, "regenerate_on_transcript_update": False, + "sync_prodtrack_tab_on_version_change": False, "updated_at": now, "created_at": now, } @@ -351,6 +357,7 @@ async def test_get_user_settings_returns_settings( assert result.note_prompt == "Custom prompt" assert result.regenerate_on_version_change is True assert result.regenerate_on_transcript_update is False + assert result.sync_prodtrack_tab_on_version_change is False @pytest.mark.asyncio async def test_get_user_settings_returns_none( @@ -375,6 +382,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection): "note_prompt": "Updated prompt", "regenerate_on_version_change": True, "regenerate_on_transcript_update": True, + "sync_prodtrack_tab_on_version_change": False, "updated_at": now, "created_at": now, } @@ -392,6 +400,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection): assert result.note_prompt == "Updated prompt" assert result.regenerate_on_version_change is True assert result.regenerate_on_transcript_update is True + assert result.sync_prodtrack_tab_on_version_change is False mock_collection.find_one_and_update.assert_called_once() @pytest.mark.asyncio diff --git a/frontend/packages/app/.env.example b/frontend/packages/app/.env.example index c408ea29..bc23100a 100644 --- a/frontend/packages/app/.env.example +++ b/frontend/packages/app/.env.example @@ -6,3 +6,9 @@ VITE_AUTH_PROVIDER=none # Required only when VITE_AUTH_PROVIDER=google VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com + +# Chrome extension ID from chrome://extensions (DNA Production Tracking Tab Sync) +# VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID= + +# Optional: link shown in the install prompt (Chrome Web Store or docs) +# VITE_PRODTRACK_TAB_SYNC_INSTALL_URL=https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md diff --git a/frontend/packages/app/src/components/ContentArea.tsx b/frontend/packages/app/src/components/ContentArea.tsx index a86ea33d..8ea16730 100644 --- a/frontend/packages/app/src/components/ContentArea.tsx +++ b/frontend/packages/app/src/components/ContentArea.tsx @@ -1,11 +1,14 @@ -import { useRef, useCallback, useMemo } from 'react'; +import { useRef, useCallback, useMemo, useEffect, useState } from 'react'; import styled from 'styled-components'; -import type { Version, SearchResult } from '@dna/core'; +import { useQuery } from '@tanstack/react-query'; +import type { Version, SearchResult, UserSettings } from '@dna/core'; import { VersionHeader } from './VersionHeader'; import { NoteEditor, type NoteEditorHandle } from './NoteEditor'; import { AssistantPanel } from './AssistantPanel'; import { usePlaylistMetadata, useSetInReview, useDraftNote } from '../hooks'; import { useHotkeyAction } from '../hotkeys'; +import { apiHandler } from '../api'; +import { openProdtrackVersionViaExtensionOrNewTab } from '../prodtrackTabSync/sendProdtrackTabSync'; interface ContentAreaProps { version?: Version | null; @@ -152,6 +155,70 @@ export function ContentArea({ enabled: !!version && !!playlistId, }); + const extensionId = + import.meta.env.VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?.trim() ?? ''; + + const [prodtrackControlledTabId, setProdtrackControlledTabId] = useState< + number | null + >(null); + const prodtrackTabIdRef = useRef(null); + prodtrackTabIdRef.current = prodtrackControlledTabId; + + const { data: userSettings, isSuccess: userSettingsQuerySuccess } = + useQuery({ + queryKey: ['userSettings', userEmail], + queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }), + enabled: !!userEmail, + }); + + + const shouldAutoSyncProdtrackTab = + userSettingsQuerySuccess && + (userSettings === null || + (userSettings.sync_prodtrack_tab_on_version_change ?? true) === true); + + const handleSyncProdtrackTab = useCallback(() => { + const url = version?.prodtrack_detail_url; + if (!url || !extensionId) return; + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, { + tabId: prodtrackControlledTabId ?? undefined, + }).then((result) => { + if (result.ok && typeof result.tabId === 'number') { + setProdtrackControlledTabId(result.tabId); + } + }); + }, [version?.prodtrack_detail_url, extensionId, prodtrackControlledTabId]); + + useEffect(() => { + if (!version?.prodtrack_detail_url) return; + if (!shouldAutoSyncProdtrackTab) return; + if (!extensionId) return; + const url = version.prodtrack_detail_url; + const timer = window.setTimeout(() => { + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, { + tabId: prodtrackTabIdRef.current ?? undefined, + }).then((result) => { + if (result.ok && typeof result.tabId === 'number') { + setProdtrackControlledTabId(result.tabId); + } + }); + }, 120); + return () => window.clearTimeout(timer); + }, [ + version?.id, + version?.prodtrack_detail_url, + shouldAutoSyncProdtrackTab, + extensionId, + ]); + + const syncProdtrackTitle = !version?.prodtrack_detail_url + ? 'Production tracking URL is not available for this version.' + : extensionId + ? 'Open in the tab sync extension when available; otherwise opens in a new tab.' + : 'Open production tracking in a new browser tab.'; + + const syncProdtrackDisabled = !version?.prodtrack_detail_url; + if (!version) { return ( @@ -179,7 +246,8 @@ export function ContentArea({ } return ( - + <> + - + + ); } diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index db596e98..e1c9c858 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -224,10 +224,12 @@ interface GeneralTabProps { notePrompt: string; regenerateOnVersionChange: boolean; regenerateOnTranscriptUpdate: boolean; + syncProdtrackTabOnVersionChange: boolean; isPending: boolean; onNotePromptChange: (e: React.ChangeEvent) => void; onRegenerateOnVersionChange: (checked: boolean) => void; onRegenerateOnTranscriptUpdate: (checked: boolean) => void; + onSyncProdtrackTabOnVersionChange: (checked: boolean) => void; } function GeneralTab({ @@ -235,10 +237,12 @@ function GeneralTab({ notePrompt, regenerateOnVersionChange, regenerateOnTranscriptUpdate, + syncProdtrackTabOnVersionChange, isPending, onNotePromptChange, onRegenerateOnVersionChange, onRegenerateOnTranscriptUpdate, + onSyncProdtrackTabOnVersionChange, }: GeneralTabProps) { const { mode, setMode } = useThemeMode(); @@ -342,6 +346,31 @@ function GeneralTab({ + +
+ Production tracking (browser) + + Requires the DNA tab sync Chrome extension. + + + + + + Sync PT tab when version changes + + + When enabled (default), the extension updates your + production-tracking tab whenever you select a different version. + Turn off to update the PT tab only with the "PT tab" + button in the version header. + + + +
); } @@ -510,6 +539,8 @@ export function SettingsModal({ useState(false); const [regenerateOnTranscriptUpdate, setRegenerateOnTranscriptUpdate] = useState(false); + const [syncProdtrackTabOnVersionChange, setSyncProdtrackTabOnVersionChange] = + useState(true); const [isDirty, setIsDirty] = useState(false); const { getAllActions, getKeysForAction, setKeysForAction, resetToDefaults } = @@ -527,7 +558,8 @@ export function SettingsModal({ mutationKey: ['upsertUserSettings', userEmail], mutationFn: (data: UserSettingsUpdate) => apiHandler.upsertUserSettings({ userEmail, data }), - onSuccess: () => { + onSuccess: (saved) => { + queryClient.setQueryData(['userSettings', userEmail], saved); queryClient.invalidateQueries({ queryKey: ['userSettings', userEmail] }); setIsDirty(false); }, @@ -542,6 +574,15 @@ export function SettingsModal({ setNotePrompt(displayPrompt); setRegenerateOnVersionChange(settings.regenerate_on_version_change); setRegenerateOnTranscriptUpdate(settings.regenerate_on_transcript_update); + setSyncProdtrackTabOnVersionChange( + settings.sync_prodtrack_tab_on_version_change ?? true + ); + setIsDirty(false); + } else if (settings === null) { + setNotePrompt(''); + setRegenerateOnVersionChange(false); + setRegenerateOnTranscriptUpdate(false); + setSyncProdtrackTabOnVersionChange(true); setIsDirty(false); } }, [settings]); @@ -564,6 +605,14 @@ export function SettingsModal({ setIsDirty(true); }, []); + const handleSyncProdtrackTabOnVersionChange = useCallback( + (checked: boolean) => { + setSyncProdtrackTabOnVersionChange(checked); + setIsDirty(true); + }, + [] + ); + const handleSave = useCallback(() => { const defaultTrimmed = (settings?.default_note_prompt ?? '').trim(); const currentTrimmed = notePrompt.trim(); @@ -573,6 +622,7 @@ export function SettingsModal({ note_prompt: persistAsDefault ? '' : notePrompt, regenerate_on_version_change: regenerateOnVersionChange, regenerate_on_transcript_update: regenerateOnTranscriptUpdate, + sync_prodtrack_tab_on_version_change: syncProdtrackTabOnVersionChange, }); }, [ mutation, @@ -580,6 +630,7 @@ export function SettingsModal({ settings?.default_note_prompt, regenerateOnVersionChange, regenerateOnTranscriptUpdate, + syncProdtrackTabOnVersionChange, ]); const handleOpenChange = useCallback( @@ -625,10 +676,14 @@ export function SettingsModal({ notePrompt={notePrompt} regenerateOnVersionChange={regenerateOnVersionChange} regenerateOnTranscriptUpdate={regenerateOnTranscriptUpdate} + syncProdtrackTabOnVersionChange={syncProdtrackTabOnVersionChange} isPending={mutation.isPending} onNotePromptChange={handleNotePromptChange} onRegenerateOnVersionChange={handleRegenerateOnVersionChange} onRegenerateOnTranscriptUpdate={handleRegenerateOnTranscriptUpdate} + onSyncProdtrackTabOnVersionChange={ + handleSyncProdtrackTabOnVersionChange + } /> diff --git a/frontend/packages/app/src/components/VersionHeader.test.tsx b/frontend/packages/app/src/components/VersionHeader.test.tsx new file mode 100644 index 00000000..aaff34e7 --- /dev/null +++ b/frontend/packages/app/src/components/VersionHeader.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '../test/render'; +import { HotkeysProvider } from '../hotkeys'; +import { VersionHeader } from './VersionHeader'; +import { apiHandler } from '../api'; + +beforeEach(() => { + vi.spyOn(apiHandler, 'getVersionStatuses').mockResolvedValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('VersionHeader', () => { + it('renders PT tab button when extension sync is enabled', () => { + const onSync = vi.fn(); + render( + + + + ); + expect(screen.getByRole('button', { name: /PT tab/i })).toBeInTheDocument(); + }); + + it('renders PT tab as a new-tab link when extension sync is off', () => { + render( + + + + ); + const link = screen.getByRole('link', { name: /PT tab/i }); + expect(link).toHaveAttribute( + 'href', + 'https://studio.shotgrid.autodesk.com/detail/Version/2' + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/frontend/packages/app/src/components/VersionHeader.tsx b/frontend/packages/app/src/components/VersionHeader.tsx index fa7a12fc..0cc47906 100644 --- a/frontend/packages/app/src/components/VersionHeader.tsx +++ b/frontend/packages/app/src/components/VersionHeader.tsx @@ -1,6 +1,14 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Tooltip } from '@radix-ui/themes'; -import { ChevronLeft, Eye, ChevronRight, RotateCw, Target, ChevronDown } from 'lucide-react'; +import { + ChevronLeft, + Eye, + ChevronRight, + RotateCw, + Target, + ChevronDown, + ExternalLink, +} from 'lucide-react'; import { UserAvatar } from './UserAvatar'; import { useHotkeyConfig } from '../hotkeys'; import { useVersionStatuses } from '../hooks'; @@ -21,6 +29,11 @@ interface VersionHeaderProps { onRefresh?: () => void; onSetInReview?: () => void; onVersionStatusChange?: (code: string) => void; + prodtrackDetailUrl?: string | null; + prodtrackTabUsesExtension?: boolean; + onSyncProdtrackTab?: () => void | Promise; + syncProdtrackDisabled?: boolean; + syncProdtrackTitle?: string; canGoBack?: boolean; canGoNext?: boolean; hasInReview?: boolean; @@ -100,6 +113,49 @@ const InReviewButton = styled.button` } `; +const syncProdtrackSurface = css` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + font-size: 14px; + font-weight: 500; + font-family: ${({ theme }) => theme.fonts.sans}; + color: ${({ theme }) => theme.colors.text.secondary}; + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.border.default}; + border-radius: ${({ theme }) => theme.radii.md}; + cursor: pointer; + transition: all ${({ theme }) => theme.transitions.fast}; + box-sizing: border-box; +`; + +const SyncProdtrackButton = styled.button` + ${syncProdtrackSurface} + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.colors.bg.surfaceHover}; + color: ${({ theme }) => theme.colors.text.primary}; + border-color: ${({ theme }) => theme.colors.border.strong}; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; + +const SyncProdtrackLink = styled.a` + ${syncProdtrackSurface} + text-decoration: none; + + &:hover { + background: ${({ theme }) => theme.colors.bg.surfaceHover}; + color: ${({ theme }) => theme.colors.text.primary}; + border-color: ${({ theme }) => theme.colors.border.strong}; + } +`; + const NextVersionButton = styled.button` display: flex; align-items: center; @@ -329,6 +385,11 @@ export function VersionHeader({ onRefresh, onSetInReview, onVersionStatusChange, + prodtrackDetailUrl, + prodtrackTabUsesExtension = false, + onSyncProdtrackTab, + syncProdtrackDisabled = false, + syncProdtrackTitle = 'Open current version in production tracking (browser tab)', canGoBack = true, canGoNext = true, hasInReview = true, @@ -354,6 +415,30 @@ export function VersionHeader({ In Review + {prodtrackDetailUrl && prodtrackTabUsesExtension && onSyncProdtrackTab && ( + + void onSyncProdtrackTab()} + disabled={syncProdtrackDisabled} + > + + PT tab + + + )} + {prodtrackDetailUrl && !prodtrackTabUsesExtension && ( + + + + PT tab + + + )} Next Version diff --git a/frontend/packages/app/src/contexts/AuthContext.tsx b/frontend/packages/app/src/contexts/AuthContext.tsx index 9ceff3f5..bf2ad9d3 100644 --- a/frontend/packages/app/src/contexts/AuthContext.tsx +++ b/frontend/packages/app/src/contexts/AuthContext.tsx @@ -64,19 +64,20 @@ function NoopAuthProviderInner({ children }: NoopAuthProviderInnerProps) { }); useEffect(() => { - if (token === 'noop-token' && user?.email) { - localStorage.setItem(STORAGE_KEY, user.email); - setToken(user.email); - } + if (token !== 'noop-token' || !user?.email) return; + localStorage.setItem(STORAGE_KEY, user.email); + setToken(user.email); }, [token, user?.email]); useEffect(() => { - if (token && user) { + const authToken = + token === 'noop-token' && user?.email ? user.email : token; + if (authToken && user) { apiHandler.setUser({ id: user.id, email: user.email, name: user.name, - token: token, + token: authToken, }); } else { apiHandler.setUser(null); @@ -98,12 +99,10 @@ function NoopAuthProviderInner({ children }: NoopAuthProviderInnerProps) { name: email.split('@')[0], }; - const noopToken = email; - - localStorage.setItem(STORAGE_KEY, noopToken); + localStorage.setItem(STORAGE_KEY, email); localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(authUser)); - setToken(noopToken); + setToken(email); setUser(authUser); }, []); diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts new file mode 100644 index 00000000..d64e02f9 --- /dev/null +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + openProdtrackVersionInExtension, + openProdtrackUrlInUncontrolledNewTab, + openProdtrackVersionViaExtensionOrNewTab, + pingProdtrackTabExtension, +} from './sendProdtrackTabSync'; + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'chrome'); +}); + +describe('openProdtrackVersionInExtension', () => { + it('returns no_extension_id when extension id is empty', async () => { + const r = await openProdtrackVersionInExtension( + ' ', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(r).toEqual({ ok: false, reason: 'no_extension_id' }); + }); + + it('returns invalid_url when url is not http(s)', async () => { + const r = await openProdtrackVersionInExtension('abc', 'ftp://x'); + expect(r).toEqual({ ok: false, reason: 'invalid_url' }); + }); + + it('returns no_chrome when chrome.runtime is missing', async () => { + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(r).toEqual({ ok: false, reason: 'no_chrome' }); + }); + + it('returns ok when extension responds with ok true (legacy, no tabId)', async () => { + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + _msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, _msg, cb) => { + cb({ ok: true }); + }, + lastError: undefined, + }, + }; + + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/99' + ); + expect(r).toEqual({ ok: true }); + }); + + it('sends tabId in message when options include it', async () => { + let lastMsg: object = {}; + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, msg, cb) => { + lastMsg = msg; + cb({ ok: true, tabId: 99 }); + }, + lastError: undefined, + }, + }; + + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1', + { tabId: 99 } + ); + expect(lastMsg).toEqual( + expect.objectContaining({ + type: 'OPEN_VERSION', + url: 'https://studio.shotgrid.autodesk.com/detail/Version/1', + tabId: 99, + }) + ); + expect(r).toEqual({ ok: true, tabId: 99 }); + }); + + it('omits tabId from message when option is not a positive id', async () => { + let lastMsg: object = {}; + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, msg, cb) => { + lastMsg = msg; + cb({ ok: true, tabId: 3 }); + }, + lastError: undefined, + }, + }; + + await openProdtrackVersionInExtension('abcdefghijklmnopabcdefghijklmnop', 'https://a.com/b', { + tabId: 0, + }); + expect((lastMsg as { tabId?: number }).tabId).toBeUndefined(); + }); +}); + +describe('openProdtrackUrlInUncontrolledNewTab', () => { + it('does nothing for non-http URLs', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + openProdtrackUrlInUncontrolledNewTab('javascript:alert(1)'); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); + + it('opens http(s) URL in a new tab and clears opener', () => { + const mockWin = { opener: {} as unknown }; + const open = vi.spyOn(window, 'open').mockImplementation(() => mockWin as Window); + openProdtrackUrlInUncontrolledNewTab('https://example.com/v/1'); + expect(open).toHaveBeenCalledWith('https://example.com/v/1', '_blank'); + expect(mockWin.opener).toBeNull(); + open.mockRestore(); + }); +}); + +describe('openProdtrackVersionViaExtensionOrNewTab', () => { + it('opens a new tab when the extension does not respond', async () => { + const mockWin = { opener: {} as unknown }; + const open = vi.spyOn(window, 'open').mockImplementation(() => mockWin as Window); + const r = await openProdtrackVersionViaExtensionOrNewTab( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(r).toEqual({ ok: false, reason: 'no_chrome' }); + expect(open).toHaveBeenCalledWith( + 'https://studio.shotgrid.autodesk.com/detail/Version/1', + '_blank' + ); + open.mockRestore(); + }); + + it('does not open a new tab when the extension succeeds', async () => { + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + _msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, _msg, cb) => { + cb({ ok: true }); + }, + lastError: undefined, + }, + }; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + const r = await openProdtrackVersionViaExtensionOrNewTab( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/2' + ); + expect(r).toEqual({ ok: true }); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); +}); + +describe('pingProdtrackTabExtension', () => { + it('returns no_extension_id when id empty', async () => { + const r = await pingProdtrackTabExtension(''); + expect(r).toEqual({ ok: false, reason: 'no_extension_id' }); + }); +}); diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts new file mode 100644 index 00000000..468b851f --- /dev/null +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts @@ -0,0 +1,188 @@ +export type ProdtrackTabSyncResult = + | { ok: true; tabId?: number } + | { + ok: false; + reason: + | 'no_chrome' + | 'no_extension_id' + | 'no_extension' + | 'invalid_url' + | 'error'; + detail?: string; + }; + +type ChromeRuntime = { + sendMessage: ( + extensionId: string, + message: object, + responseCallback?: (response: unknown) => void + ) => void; + lastError?: { message?: string }; +}; + +function getChromeRuntime(): ChromeRuntime | undefined { + if (typeof globalThis === 'undefined') return undefined; + const chromeApi = ( + globalThis as { + chrome?: { runtime?: ChromeRuntime }; + } + ).chrome; + return chromeApi?.runtime; +} + +function sendExternalMessage( + extensionId: string, + message: object, + timeoutMs: number +): Promise { + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const timer = window.setTimeout(() => resolve(undefined), timeoutMs); + try { + runtime.sendMessage(extensionId, message, (response: unknown) => { + window.clearTimeout(timer); + if (runtime.lastError?.message) { + resolve({ __error: runtime.lastError.message }); + return; + } + resolve(response); + }); + } catch (e) { + window.clearTimeout(timer); + resolve({ __error: e instanceof Error ? e.message : String(e) }); + } + }); +} + +export type OpenVersionOptions = { + /** Last known controlled tab id from a prior OPEN_VERSION; forwarded to the extension */ + tabId?: number; + /** Defaults to 800 */ + timeoutMs?: number; +}; + +function parseOpenVersionResponse( + raw: unknown +): { ok: true; tabId?: number } | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.ok !== true) return null; + if (typeof o.tabId === 'number' && Number.isFinite(o.tabId)) { + return { ok: true, tabId: o.tabId }; + } + return { ok: true }; +} + +function parsePingResponse(raw: unknown): boolean { + if (!raw || typeof raw !== 'object') return false; + return (raw as { ok?: unknown }).ok === true; +} + +/** Opens the production-tracking URL in a normal new browser tab (not extension-controlled). */ +export function openProdtrackUrlInUncontrolledNewTab(url: string): void { + if (!url.startsWith('http')) return; + if (typeof window === 'undefined' || typeof window.open !== 'function') return; + const opened = window.open(url, '_blank'); + if (opened) { + opened.opener = null; + } +} + +export async function pingProdtrackTabExtension( + extensionId: string, + timeoutMs = 400 +): Promise { + const trimmed = extensionId.trim(); + if (!trimmed) { + return { ok: false, reason: 'no_extension_id' }; + } + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return { ok: false, reason: 'no_chrome' }; + } + + const raw = await sendExternalMessage(trimmed, { type: 'PING' }, timeoutMs); + if (raw && typeof raw === 'object' && '__error' in raw) { + return { + ok: false, + reason: 'no_extension', + detail: String((raw as { __error: string }).__error), + }; + } + if (raw === undefined || !parsePingResponse(raw)) { + return { ok: false, reason: 'no_extension' }; + } + return { ok: true }; +} + +/** + * @param timeoutOrOptions — A millisecond timeout (default 800) or open options + * including `tabId` (last known controlled tab) and `timeoutMs`. + */ +export async function openProdtrackVersionInExtension( + extensionId: string, + url: string, + timeoutOrOptions: number | OpenVersionOptions = 800 +): Promise { + const trimmed = extensionId.trim(); + if (!trimmed) { + return { ok: false, reason: 'no_extension_id' }; + } + if (!url.startsWith('http')) { + return { ok: false, reason: 'invalid_url' }; + } + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return { ok: false, reason: 'no_chrome' }; + } + + const openOpts = + typeof timeoutOrOptions === 'number' ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions; + const timeoutMs = openOpts.timeoutMs ?? 800; + const lastKnownTabId = openOpts.tabId; + + const message: { type: string; url: string; tabId?: number } = { + type: 'OPEN_VERSION', + url, + }; + if (typeof lastKnownTabId === 'number' && lastKnownTabId > 0) { + message.tabId = lastKnownTabId; + } + + const raw = await sendExternalMessage(trimmed, message, timeoutMs); + + if (raw && typeof raw === 'object' && '__error' in raw) { + return { + ok: false, + reason: 'error', + detail: String((raw as { __error: string }).__error), + }; + } + + const ack = parseOpenVersionResponse(raw); + if (ack != null) { + return { ok: true, tabId: ack.tabId }; + } + + return { ok: false, reason: 'no_extension' }; +} + +export async function openProdtrackVersionViaExtensionOrNewTab( + extensionId: string, + url: string, + timeoutOrOptions: number | OpenVersionOptions = 800 +): Promise { + const result = await openProdtrackVersionInExtension( + extensionId, + url, + timeoutOrOptions + ); + if (!result.ok) { + openProdtrackUrlInUncontrolledNewTab(url); + } + return result; +} diff --git a/frontend/packages/app/src/vite-env.d.ts b/frontend/packages/app/src/vite-env.d.ts index 11f02fe2..76e65a9e 100644 --- a/frontend/packages/app/src/vite-env.d.ts +++ b/frontend/packages/app/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + readonly VITE_WS_URL: string; + readonly VITE_AUTH_PROVIDER?: string; + readonly VITE_GOOGLE_CLIENT_ID?: string; + readonly VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?: string; + readonly VITE_PRODTRACK_TAB_SYNC_INSTALL_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/packages/core/src/interfaces.ts b/frontend/packages/core/src/interfaces.ts index b539c1d5..0e46f278 100644 --- a/frontend/packages/core/src/interfaces.ts +++ b/frontend/packages/core/src/interfaces.ts @@ -83,6 +83,7 @@ export interface Version extends EntityBase { entity?: Shot | Asset; task?: Task; notes: Note[]; + prodtrack_detail_url?: string; } export interface Playlist extends EntityBase { @@ -334,6 +335,7 @@ export interface UserSettings { default_note_prompt: string; regenerate_on_version_change: boolean; regenerate_on_transcript_update: boolean; + sync_prodtrack_tab_on_version_change: boolean; updated_at: string; created_at: string; } @@ -342,6 +344,7 @@ export interface UserSettingsUpdate { note_prompt?: string; regenerate_on_version_change?: boolean; regenerate_on_transcript_update?: boolean; + sync_prodtrack_tab_on_version_change?: boolean; } export interface GetUserSettingsParams { diff --git a/prodtrack-tab-sync-extension/README.md b/prodtrack-tab-sync-extension/README.md new file mode 100644 index 00000000..d8e5b9b2 --- /dev/null +++ b/prodtrack-tab-sync-extension/README.md @@ -0,0 +1,40 @@ +# DNA Production Tracking Tab Sync (Chrome extension) + +Chrome extension (Manifest V3) that pairs with the DNA web app ([issue #136](https://github.com/AcademySoftwareFoundation/dna/issues/136)): DNA sends the ShotGrid / production-tracking version detail URL, and this extension opens or updates a **single controlled tab** next to your DNA tab when possible. + +## Install (development) + +1. Open Chrome → **Extensions** → enable **Developer mode**. +2. **Load unpacked** → select this folder `prodtrack-tab-sync-extension/`. +3. Copy the extension **ID** from the card (32-char string). +4. In DNA frontend, set `VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID` to that ID in `frontend/packages/app/.env` and restart the dev server. + +## Allow DNA origins + +The extension accepts external messages from: + +- **`https://*/*`** — any HTTPS origin (typical production deployments on arbitrary domains). +- **`*://localhost/*`** and **`*://127.0.0.1/*`** — local dev on any port (`http` or `https`). + +[`externally_connectable`](https://developer.chrome.com/docs/extensions/reference/manifest/externally-connectable) cannot use a catch‑all like `http://*/*` for every hostname; Chrome treats that pattern as invalid for web pages. So **HTTP deployments that are not** `localhost` / `127.0.0.1` (for example `http://dna.corp.local/`) must add an explicit entry to `matches` in [`manifest.json`](./manifest.json), then reload the extension in `chrome://extensions`. + +`"ids": ["*"]` allows other extensions to message this one; it does not change which **websites** can connect (still governed by `matches` only). + +## Chrome Web Store + +When published, the install prompt in DNA can point users to the listing URL via `VITE_PRODTRACK_TAB_SYNC_INSTALL_URL` (see DNA `.env.example`). + +## Split view + +Chrome exposes [`tabs.Tab.splitViewId`](https://developer.chrome.com/docs/extensions/reference/api/tabs#property-Tab-splitViewId) and [`tabs.SPLIT_VIEW_ID_NONE`](https://developer.chrome.com/docs/extensions/reference/api/tabs#property-SPLIT_VIEW_ID_NONE) (Chrome 140+) so extensions can **see** which tabs share a split; it does **not** yet expose a supported way to **create** a split from an extension (see [WECG discussion](https://github.com/w3c/webextensions/issues/967)). + +This extension therefore: + +1. Opens the production-tracking tab in the **same window**, **immediately after** the active (DNA) tab, and sets **`openerTabId`** to the DNA tab when possible so the browser can treat it as a related tab. +2. **Best-effort only:** if the DNA tab is **already** in a split view (`splitViewId` not `SPLIT_VIEW_ID_NONE`), the extension tries `chrome.tabs.update` on the controlled tab with that `splitViewId`. That call is **not** part of the published `tabs.update` schema today; it is wrapped in `try/catch` and ignored on failure. If Chrome adds support later, the same code may start attaching without changes. +3. Otherwise behavior matches a **normal adjacent tab** (manual split via Chrome UI if you want a tiled layout). + +## Message protocol (DNA → extension) + +- `{ "type": "PING" }` → `{ "ok": true, "pong": true }` (presence check). +- `{ "type": "OPEN_VERSION", "url": "", "tabId"?: }` — `tabId` is optional: last known Chrome **tab** id of the production-tracking window from a prior `OPEN_VERSION` success. The extension **tries that id first** (if still open); if it is missing, it uses split-view heuristics, the extension’s in-memory id, or creates a new tab. Response: `{ "ok": true, "tabId": }` (the id that was navigated) or `{ "ok": false, "error": "..." }`. diff --git a/prodtrack-tab-sync-extension/background.js b/prodtrack-tab-sync-extension/background.js new file mode 100644 index 00000000..3dc03e2e --- /dev/null +++ b/prodtrack-tab-sync-extension/background.js @@ -0,0 +1,283 @@ +let controlledTabId = null; + +function reply(sendResponse, payload) { + try { + sendResponse(payload); + } catch { + /* channel may be closed */ + } +} + +function splitViewNoneConstant() { + if (typeof chrome.tabs?.SPLIT_VIEW_ID_NONE === 'number') { + return chrome.tabs.SPLIT_VIEW_ID_NONE; + } + return -1; +} + +function tabSplitViewId(tab) { + const v = tab?.splitViewId; + if (typeof v !== 'number') return splitViewNoneConstant(); + return v; +} + +function tabOrigin(url) { + if (!url || typeof url !== 'string') return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function sameOrigin(a, b) { + if (!a || !b) return false; + return a === b; +} + +/** + * If the anchor tab is already in a Chrome split view, try to attach the + * controlled tab to the same split. Chrome 140+ exposes splitViewId on Tab; + * tabs.update(splitViewId) is not in the public schema yet — this is a + * forward-compatible best-effort (see README). Always wrapped in try/catch. + */ +async function tryAttachControlledToAnchorSplit(anchorTabId, controlledTabId) { + if (anchorTabId == null || controlledTabId == null) return false; + const none = splitViewNoneConstant(); + try { + const anchor = await chrome.tabs.get(anchorTabId); + const sid = tabSplitViewId(anchor); + if (sid === none) return false; + await chrome.tabs.update(controlledTabId, { splitViewId: sid }); + return true; + } catch { + return false; + } +} + +async function getTabsInWindow(windowId) { + if (typeof windowId !== 'number') return []; + try { + return await chrome.tabs.query({ windowId }); + } catch { + return []; + } +} + +/** + * Prefer the tab that sent the external message (always DNA). When the user + * focuses the other split pane, getLastFocused's active tab is not DNA; + * sender.tab and split-group origin matching fix that. + */ +async function resolveDnaAnchorTab(sender) { + const senderTabId = sender?.tab?.id; + if (typeof senderTabId === 'number') { + try { + const t = await chrome.tabs.get(senderTabId); + if (t?.id != null) return t; + } catch { + /* tab gone */ + } + } + + const senderOrigin = tabOrigin(sender?.url); + const win = await chrome.windows.getLastFocused({ populate: true }); + if (!win?.tabs?.length) return null; + const active = win.tabs.find((t) => t.active) ?? null; + if (!active) return null; + + const none = splitViewNoneConstant(); + const sid = tabSplitViewId(active); + const candidates = + sid !== none + ? win.tabs.filter((t) => tabSplitViewId(t) === sid) + : [active]; + + if (senderOrigin) { + const dnaInGroup = candidates.find((t) => sameOrigin(tabOrigin(t?.url), senderOrigin)); + if (dnaInGroup) return dnaInGroup; + } + + return active; +} + +/** + * Any other tab sharing DNA's splitViewId (the other pane). Prefer one that + * already matches the prodtrack URL hostname when several share the split. + */ +function findSplitViewPartnerTab(dnaAnchor, tabs, prodtrackUrl) { + const none = splitViewNoneConstant(); + const sid = tabSplitViewId(dnaAnchor); + if (sid === none) return null; + + const others = tabs.filter( + (t) => typeof t.id === 'number' && t.id !== dnaAnchor.id && tabSplitViewId(t) === sid + ); + if (!others.length) return null; + + let targetHost = null; + try { + targetHost = new URL(prodtrackUrl).hostname; + } catch { + /* ignore */ + } + if (targetHost) { + const sameHost = others.find((t) => { + try { + return new URL(t.url).hostname === targetHost; + } catch { + return false; + } + }); + if (sameHost) return sameHost; + } + + return [...others].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))[0]; +} + +/** + * @param {string} url + * @param {chrome.runtime.MessageSender} sender + * @param {unknown} [clientTabId] — last known tab id from the page; used first if still valid + * @returns {Promise} Chrome tab id that was navigated, or null on total failure + */ +async function openOrUpdateControlledTab(url, sender, clientTabId) { + const updateTabById = async (id) => { + if (id == null) return null; + try { + const tab = await chrome.tabs.get(id); + if (tab?.id != null) { + await chrome.tabs.update(tab.id, { url, active: false }); + return tab.id; + } + } catch { + /* tab closed */ + } + return null; + }; + + if (typeof clientTabId === 'number' && clientTabId > 0) { + const fromClient = await updateTabById(clientTabId); + if (fromClient != null) { + controlledTabId = fromClient; + const dnaTab = await resolveDnaAnchorTab(sender); + let dnaAnchorForSplit = dnaTab; + if (dnaTab?.id != null) { + try { + dnaAnchorForSplit = await chrome.tabs.get(dnaTab.id); + } catch { + /* use resolved */ + } + } + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, fromClient); + } + return fromClient; + } + } + + const dnaTab = await resolveDnaAnchorTab(sender); + let dnaAnchorForSplit = dnaTab; + if (dnaTab?.id != null) { + try { + dnaAnchorForSplit = await chrome.tabs.get(dnaTab.id); + } catch { + /* use resolved tab */ + } + } + const windowTabs = + dnaAnchorForSplit?.windowId != null + ? await getTabsInWindow(dnaAnchorForSplit.windowId) + : []; + const splitPartner = + dnaAnchorForSplit != null + ? findSplitViewPartnerTab(dnaAnchorForSplit, windowTabs, url) + : null; + + if (splitPartner?.id != null) { + try { + await chrome.tabs.update(splitPartner.id, { url, active: false }); + controlledTabId = splitPartner.id; + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, splitPartner.id); + } + return splitPartner.id; + } catch { + /* fall through: try tracked tab or create */ + } + } + + const fromTracked = await updateTabById(controlledTabId); + if (fromTracked != null) { + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, fromTracked); + } + return fromTracked; + } + + controlledTabId = null; + + const createProps = { url, active: false }; + + if (dnaAnchorForSplit?.windowId != null) { + createProps.windowId = dnaAnchorForSplit.windowId; + if (typeof dnaAnchorForSplit.index === 'number') { + createProps.index = dnaAnchorForSplit.index + 1; + } + if (typeof dnaAnchorForSplit.id === 'number') { + createProps.openerTabId = dnaAnchorForSplit.id; + } + } + + const created = await chrome.tabs.create(createProps); + if (created?.id != null) { + controlledTabId = created.id; + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, created.id); + } + return created.id; + } + return null; +} + +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabId === controlledTabId) controlledTabId = null; +}); + +chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { + if (!message || typeof message !== 'object') { + reply(sendResponse, { ok: false, error: 'invalid_message' }); + return; + } + + if (message.type === 'PING') { + reply(sendResponse, { ok: true, pong: true }); + return true; + } + + if (message.type === 'OPEN_VERSION') { + const url = message.url; + if (typeof url !== 'string' || !url.startsWith('http')) { + reply(sendResponse, { ok: false, error: 'invalid_url' }); + return true; + } + openOrUpdateControlledTab(url, sender, message.tabId) + .then((tabId) => { + if (typeof tabId === 'number') { + reply(sendResponse, { ok: true, tabId }); + } else { + reply(sendResponse, { ok: false, error: 'no_tab' }); + } + }) + .catch((err) => + reply(sendResponse, { + ok: false, + error: err?.message || String(err), + }) + ); + return true; + } + + reply(sendResponse, { ok: false, error: 'unknown_type' }); + return true; +}); diff --git a/prodtrack-tab-sync-extension/manifest.json b/prodtrack-tab-sync-extension/manifest.json new file mode 100644 index 00000000..4a39ccee --- /dev/null +++ b/prodtrack-tab-sync-extension/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "DNA Production Tracking Tab Sync", + "version": "0.1.0", + "description": "Keeps a controlled ShotGrid / production-tracking tab in sync with DNA version selection.", + "background": { + "service_worker": "background.js" + }, + "permissions": ["tabs"], + "host_permissions": ["https://*/*", "http://*/*"], + "externally_connectable": { + "ids": ["*"], + "matches": [ + "https://*/*", + "*://localhost/*", + "*://127.0.0.1/*" + ] + } +}