diff --git a/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py b/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py new file mode 100644 index 00000000..2b4bd558 --- /dev/null +++ b/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py @@ -0,0 +1,42 @@ +"""add neuroglancer_states table + +Revision ID: 2d1f0e6b8c91 +Revises: 9812335c52b6 +Create Date: 2025-10-22 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2d1f0e6b8c91' +down_revision = '9812335c52b6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'neuroglancer_states', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('short_key', sa.String(), nullable=False), + sa.Column('short_name', sa.String(), nullable=True), + sa.Column('username', sa.String(), nullable=False), + sa.Column('url_base', sa.String(), nullable=False), + sa.Column('state', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('short_key', name='uq_neuroglancer_states_short_key') + ) + op.create_index( + 'ix_neuroglancer_states_short_key', + 'neuroglancer_states', + ['short_key'], + unique=True + ) + + +def downgrade() -> None: + op.drop_index('ix_neuroglancer_states_short_key', table_name='neuroglancer_states') + op.drop_table('neuroglancer_states') diff --git a/fileglancer/app.py b/fileglancer/app.py index 5b2beeec..74e377d5 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -149,6 +149,46 @@ def _validate_filename(name: str) -> None: raise HTTPException(status_code=400, detail="File or directory name cannot have leading or trailing whitespace") +def _parse_neuroglancer_url(url: str) -> Tuple[str, Dict]: + """ + Parse a Neuroglancer URL and return its base URL and decoded JSON state. + """ + if not url or "#!" not in url: + raise HTTPException(status_code=400, detail="Neuroglancer URL must include a '#!' state fragment") + + url_base, encoded_state = url.split("#!", 1) + if not url_base.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Neuroglancer URL must start with http or https") + + decoded_state = unquote(encoded_state) + if decoded_state.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Shortened Neuroglancer URLs are not supported; provide a full state URL") + + try: + state = json.loads(decoded_state) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Neuroglancer state must be valid JSON") + + if not isinstance(state, dict): + raise HTTPException(status_code=400, detail="Neuroglancer state must be a JSON object") + + return url_base, state + + +def _normalize_short_key(short_key: Optional[str]) -> Optional[str]: + if short_key is None: + return None + normalized = short_key.strip() + return normalized or None + + +def _validate_short_key(short_key: str) -> None: + if len(short_key) < 4 or len(short_key) > 64: + raise HTTPException(status_code=400, detail="short_key must be between 4 and 64 characters") + if not all(ch.isalnum() or ch in ("-", "_") for ch in short_key): + raise HTTPException(status_code=400, detail="short_key may only contain letters, numbers, '-' or '_'") + + def create_app(settings): # Initialize OAuth client for OKTA @@ -643,6 +683,121 @@ async def delete_preference(key: str, username: str = Depends(get_current_user)) return {"message": f"Preference {key} deleted for user {username}"} + @app.post("/api/neuroglancer/nglinks", response_model=NeuroglancerShortenResponse, + description="Store a Neuroglancer state and return a shortened link") + async def shorten_neuroglancer_state(request: Request, + payload: NeuroglancerShortenRequest, + username: str = Depends(get_current_user)): + short_key = _normalize_short_key(payload.short_key) + if short_key: + _validate_short_key(short_key) + short_name = payload.short_name.strip() if payload.short_name else None + title = payload.title.strip() if payload.title else None + + if payload.url and payload.state: + raise HTTPException(status_code=400, detail="Provide either url or state, not both") + + if payload.url: + url_base, state = _parse_neuroglancer_url(payload.url.strip()) + elif payload.state: + if not payload.url_base: + raise HTTPException(status_code=400, detail="url_base is required when providing state directly") + if not isinstance(payload.state, dict): + raise HTTPException(status_code=400, detail="state must be a JSON object") + url_base = payload.url_base.strip() + if not url_base.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="url_base must start with http or https") + state = payload.state + else: + raise HTTPException(status_code=400, detail="Either url or state must be provided") + + # Add title to state if provided + if title: + state = {**state, "title": title} + + with db.get_db_session(settings.db_url) as session: + try: + entry = db.create_neuroglancer_state( + session, + username, + url_base, + state, + short_key=short_key, + short_name=short_name + ) + created_short_key = entry.short_key + created_short_name = entry.short_name + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + + # Generate URL based on whether short_name is provided + if created_short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=created_short_key, short_name=created_short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=created_short_key)) + neuroglancer_url = f"{url_base}#!{state_url}" + return NeuroglancerShortenResponse( + short_key=created_short_key, + short_name=created_short_name, + title=title, + state_url=state_url, + neuroglancer_url=neuroglancer_url + ) + + + @app.put("/api/neuroglancer/nglinks/{short_key}", response_model=NeuroglancerShortenResponse, + description="Update a stored Neuroglancer state") + async def update_neuroglancer_short_link(request: Request, + short_key: str, + payload: NeuroglancerUpdateRequest, + username: str = Depends(get_current_user)): + title = payload.title.strip() if payload.title else None + url_base, state = _parse_neuroglancer_url(payload.url.strip()) + + # Add title to state if provided + if title: + state = {**state, "title": title} + + with db.get_db_session(settings.db_url) as session: + entry = db.update_neuroglancer_state( + session, + username, + short_key, + url_base, + state + ) + if not entry: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + # Extract values before session closes + updated_short_key = entry.short_key + updated_short_name = entry.short_name + + # Generate URL based on whether short_name is present + if updated_short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=updated_short_key, short_name=updated_short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=updated_short_key)) + neuroglancer_url = f"{url_base}#!{state_url}" + return NeuroglancerShortenResponse( + short_key=updated_short_key, + short_name=updated_short_name, + title=title, + state_url=state_url, + neuroglancer_url=neuroglancer_url + ) + + + @app.delete("/api/neuroglancer/nglinks/{short_key}", + description="Delete a stored Neuroglancer state") + async def delete_neuroglancer_short_link(short_key: str = Path(..., description="The short key of the Neuroglancer state"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + deleted = db.delete_neuroglancer_state(session, username, short_key) + if deleted == 0: + raise HTTPException(status_code=404, detail="Neuroglancer link not found") + return {"message": f"Neuroglancer link {short_key} deleted"} + + @app.post("/api/proxied-path", response_model=ProxiedPath, description="Create a new proxied path") async def create_proxied_path(fsp_name: str = Query(..., description="The name of the file share path that this proxied path is associated with"), @@ -713,6 +868,59 @@ async def delete_proxied_path(sharing_key: str = Path(..., description="The shar return {"message": f"Proxied path {sharing_key} deleted for user {username}"} + @app.get("/ng/{short_key}", name="get_neuroglancer_state_simple", include_in_schema=False) + async def get_neuroglancer_state_simple(short_key: str = Path(..., description="Short key for a stored Neuroglancer state")): + with db.get_db_session(settings.db_url) as session: + entry = db.get_neuroglancer_state(session, short_key) + if not entry: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + # If this entry has a short_name, require it in the URL + if entry.short_name: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) + + @app.get("/ng/{short_key}/{short_name}", name="get_neuroglancer_state", include_in_schema=False) + async def get_neuroglancer_state(short_key: str = Path(..., description="Short key for a stored Neuroglancer state"), + short_name: str = Path(..., description="Short name for a stored Neuroglancer state")): + with db.get_db_session(settings.db_url) as session: + entry = db.get_neuroglancer_state(session, short_key) + if not entry: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + # Validate short_name matches + if entry.short_name != short_name: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) + + + @app.get("/api/neuroglancer/nglinks", response_model=NeuroglancerShortLinkResponse, + description="List stored Neuroglancer short links for the current user") + async def get_neuroglancer_short_links(request: Request, + username: str = Depends(get_current_user)): + links = [] + with db.get_db_session(settings.db_url) as session: + entries = db.get_neuroglancer_states(session, username) + for entry in entries: + # Generate URL based on whether short_name is provided + if entry.short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=entry.short_key, short_name=entry.short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=entry.short_key)) + neuroglancer_url = f"{entry.url_base}#!{state_url}" + # Read title from the stored state + title = entry.state.get("title") if isinstance(entry.state, dict) else None + links.append(NeuroglancerShortLink( + short_key=entry.short_key, + short_name=entry.short_name, + title=title, + created_at=entry.created_at, + updated_at=entry.updated_at, + state_url=state_url, + neuroglancer_url=neuroglancer_url + )) + + return NeuroglancerShortLinkResponse(links=links) + + @app.get("/files/{sharing_key}/{sharing_name}") @app.get("/files/{sharing_key}/{sharing_name}/{path:path}") async def target_dispatcher(request: Request, diff --git a/fileglancer/database.py b/fileglancer/database.py index 50f79b5b..d3795fdf 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -18,6 +18,7 @@ # Constants SHARING_KEY_LENGTH = 12 +NEUROGLANCER_SHORT_KEY_LENGTH = 12 # Global flag to track if migrations have been run _migrations_run = False @@ -102,6 +103,20 @@ class ProxiedPathDB(Base): ) +class NeuroglancerStateDB(Base): + """Database model for storing Neuroglancer states""" + __tablename__ = 'neuroglancer_states' + + id = Column(Integer, primary_key=True, autoincrement=True) + short_key = Column(String, nullable=False, unique=True, index=True) + short_name = Column(String, nullable=True) + username = Column(String, nullable=False) + url_base = Column(String, nullable=False) + state = Column(JSON, nullable=False) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) + + class TicketDB(Base): """Database model for storing proxied paths""" __tablename__ = 'tickets' @@ -571,6 +586,97 @@ def delete_proxied_path(session: Session, username: str, sharing_key: str): _invalidate_sharing_key_cache(sharing_key) +def _generate_unique_neuroglancer_key(session: Session) -> str: + """Generate a unique short key for Neuroglancer states.""" + for _ in range(10): + candidate = secrets.token_urlsafe(NEUROGLANCER_SHORT_KEY_LENGTH) + exists = session.query(NeuroglancerStateDB).filter_by(short_key=candidate).first() + if not exists: + return candidate + raise RuntimeError("Failed to generate a unique Neuroglancer short key") + + +def _validate_custom_neuroglancer_key(session: Session, short_key: str) -> None: + """Ensure the custom short key is available.""" + exists = session.query(NeuroglancerStateDB).filter_by(short_key=short_key).first() + if exists: + raise ValueError("Short key is already in use") + + +def create_neuroglancer_state( + session: Session, + username: str, + url_base: str, + state: Dict, + short_key: Optional[str] = None, + short_name: Optional[str] = None +) -> NeuroglancerStateDB: + """Create a new Neuroglancer state entry and return it.""" + if short_key: + _validate_custom_neuroglancer_key(session, short_key) + else: + short_key = _generate_unique_neuroglancer_key(session) + now = datetime.now(UTC) + entry = NeuroglancerStateDB( + short_key=short_key, + short_name=short_name, + username=username, + url_base=url_base, + state=state, + created_at=now, + updated_at=now + ) + session.add(entry) + session.commit() + return entry + + +def get_neuroglancer_state(session: Session, short_key: str) -> Optional[NeuroglancerStateDB]: + """Get a Neuroglancer state by short key.""" + return session.query(NeuroglancerStateDB).filter_by(short_key=short_key).first() + + +def get_neuroglancer_states(session: Session, username: str) -> List[NeuroglancerStateDB]: + """Get all Neuroglancer states for a user, newest first.""" + return ( + session.query(NeuroglancerStateDB) + .filter_by(username=username) + .order_by(NeuroglancerStateDB.created_at.desc()) + .all() + ) + + +def update_neuroglancer_state( + session: Session, + username: str, + short_key: str, + url_base: str, + state: Dict +) -> Optional[NeuroglancerStateDB]: + """Update a Neuroglancer state entry. Returns the updated entry or None if not found.""" + entry = session.query(NeuroglancerStateDB).filter_by( + short_key=short_key, + username=username + ).first() + if not entry: + return None + entry.url_base = url_base + entry.state = state + entry.updated_at = datetime.now(UTC) + session.commit() + return entry + + +def delete_neuroglancer_state(session: Session, username: str, short_key: str) -> int: + """Delete a Neuroglancer state entry. Returns the number of deleted rows.""" + deleted = session.query(NeuroglancerStateDB).filter_by( + short_key=short_key, + username=username + ).delete() + session.commit() + return deleted + + def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]: """Get tickets for a user, optionally filtered by fsp_name and path""" logger.info(f"Getting tickets for {username} with fsp_name={fsp_name} and path={path}") diff --git a/fileglancer/filestore.py b/fileglancer/filestore.py index f25f3b67..0ecff0b5 100644 --- a/fileglancer/filestore.py +++ b/fileglancer/filestore.py @@ -183,8 +183,13 @@ def _get_file_info_from_path(self, full_path: str, current_user: str = None) -> Get the FileInfo for a file or directory at the given path. """ stat_result = os.stat(full_path) - # Regenerate the relative path to ensure it is not empty (None and empty string are converted to '.' here) - rel_path = os.path.relpath(full_path, self.root_path) + # Use real paths to avoid /var vs /private/var mismatches on macOS. + root_real = os.path.realpath(self.root_path) + full_real = os.path.realpath(full_path) + if full_real == root_real: + rel_path = '.' + else: + rel_path = os.path.relpath(full_real, root_real) return FileInfo.from_stat(rel_path, full_path, stat_result, current_user) diff --git a/fileglancer/model.py b/fileglancer/model.py index 2156675a..425f83f2 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -214,3 +214,96 @@ class NotificationResponse(BaseModel): notifications: List[Notification] = Field( description="A list of active notifications" ) + + +class NeuroglancerShortenRequest(BaseModel): + """Request payload for creating a shortened Neuroglancer state""" + short_key: Optional[str] = Field( + description="Optional short key to use instead of a generated one", + default=None + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) + url: Optional[str] = Field( + description="Neuroglancer URL containing the encoded JSON state after #!", + default=None + ) + state: Optional[Dict] = Field( + description="Neuroglancer state as a JSON object", + default=None + ) + url_base: Optional[str] = Field( + description="Base Neuroglancer URL, required when providing state directly", + default=None + ) + + +class NeuroglancerUpdateRequest(BaseModel): + """Request payload for updating a Neuroglancer state""" + url: str = Field( + description="Neuroglancer URL containing the encoded JSON state after #!" + ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) + + +class NeuroglancerShortenResponse(BaseModel): + """Response payload for shortened Neuroglancer state""" + short_key: str = Field( + description="Short key for retrieving the stored state" + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) + state_url: str = Field( + description="Absolute URL to the stored state JSON" + ) + neuroglancer_url: str = Field( + description="Neuroglancer URL that references the stored state" + ) + + +class NeuroglancerShortLink(BaseModel): + """Stored Neuroglancer short link""" + short_key: str = Field( + description="Short key for retrieving the stored state" + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) + created_at: datetime = Field( + description="When this short link was created" + ) + updated_at: datetime = Field( + description="When this short link was last updated" + ) + state_url: str = Field( + description="Absolute URL to the stored state JSON" + ) + neuroglancer_url: str = Field( + description="Neuroglancer URL that references the stored state" + ) + + +class NeuroglancerShortLinkResponse(BaseModel): + links: List[NeuroglancerShortLink] = Field( + description="A list of stored Neuroglancer short links" + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e62dafa7..fd7c97c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; +import NGLinks from '@/components/NGLinks'; import Notifications from '@/components/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -98,6 +99,14 @@ const AppComponent = () => { } path="links" /> + + + + } + path="nglinks" + /> {tasksEnabled ? ( (undefined); + const [deleteItem, setDeleteItem] = useState(undefined); + + const handleOpenCreate = () => { + setEditItem(undefined); + setShowDialog(true); + }; + + const handleOpenEdit = (item: NGLink) => { + setEditItem(item); + setShowDialog(true); + }; + + const handleClose = () => { + setShowDialog(false); + setEditItem(undefined); + }; + + const handleCreate = async (payload: { + url: string; + short_name?: string; + title?: string; + }) => { + try { + await createNGLinkMutation.mutateAsync(payload); + toast.success('Link created'); + handleClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to create link'; + toast.error(message); + } + }; + + const handleUpdate = async (payload: { + short_key: string; + url: string; + title?: string; + }) => { + try { + await updateNGLinkMutation.mutateAsync(payload); + toast.success('Link updated'); + handleClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update link'; + toast.error(message); + } + }; + + const handleOpenDelete = (item: NGLink) => { + setDeleteItem(item); + }; + + const handleCloseDelete = () => { + setDeleteItem(undefined); + }; + + const handleConfirmDelete = async () => { + if (!deleteItem) { + return; + } + try { + await deleteNGLinkMutation.mutateAsync(deleteItem.short_key); + toast.success('Link deleted'); + handleCloseDelete(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to delete link'; + toast.error(message); + } + }; + + const ngLinksColumns = useNGLinksColumns(handleOpenEdit, handleOpenDelete); + + return ( + <> + + Neuroglancer Links + + + Store your Neuroglancer states for easy sharing. Create a short link and + share it with internal collaborators. You can update the link later if + needed. + +
+ +
+ + {showDialog ? ( + + ) : null} + {deleteItem ? ( + + + Are you sure you want to delete " + {deleteItem.short_name || deleteItem.short_key}"? + +
+ + +
+
+ ) : null} + + ); +} diff --git a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx new file mode 100644 index 00000000..5b11aeb3 --- /dev/null +++ b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Typography } from '@material-tailwind/react'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import type { NGLink } from '@/queries/ngLinkQueries'; + +type CreatePayload = { + url: string; + short_name?: string; + title?: string; +}; + +type UpdatePayload = { + short_key: string; + url: string; + title?: string; +}; + +type NGLinkDialogProps = { + readonly open: boolean; + readonly pending: boolean; + readonly onClose: () => void; + readonly onCreate?: (payload: CreatePayload) => Promise; + readonly onUpdate?: (payload: UpdatePayload) => Promise; + readonly editItem?: NGLink; +}; + +export default function NGLinkDialog({ + open, + pending, + onClose, + onCreate, + onUpdate, + editItem +}: NGLinkDialogProps) { + const isEditMode = !!editItem; + + const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); + const [shortName, setShortName] = useState(''); + const [title, setTitle] = useState(''); + const [error, setError] = useState(null); + + // Initialize form values when editItem changes + useEffect(() => { + if (editItem) { + setNeuroglancerUrl(editItem.neuroglancer_url); + setShortName(editItem.short_name || ''); + setTitle(editItem.title || ''); + } else { + setNeuroglancerUrl(''); + setShortName(''); + setTitle(''); + } + }, [editItem]); + + const resetAndClose = () => { + setError(null); + setNeuroglancerUrl(''); + setShortName(''); + setTitle(''); + onClose(); + }; + + const handleSubmit = async () => { + setError(null); + + if (!neuroglancerUrl.trim()) { + setError('Please provide a Neuroglancer link.'); + return; + } + + if (isEditMode && onUpdate && editItem) { + await onUpdate({ + short_key: editItem.short_key, + url: neuroglancerUrl.trim(), + title: title.trim() || undefined + }); + } else if (onCreate) { + await onCreate({ + url: neuroglancerUrl.trim(), + short_name: shortName.trim() || undefined, + title: title.trim() || undefined + }); + } + }; + + return ( + +
+ + {isEditMode + ? 'Edit Neuroglancer Short Link' + : 'Create Neuroglancer Short Link'} + + + Original Neuroglancer Link + + ) => + setNeuroglancerUrl(e.target.value) + } + placeholder="https://neuroglancer-demo.appspot.com/#!{...}" + type="text" + value={neuroglancerUrl} + /> + + Title (optional, appears in tab name) + + ) => + setTitle(e.target.value) + } + placeholder="Example: Hemibrain EM" + type="text" + value={title} + /> + {!isEditMode ? ( + <> + + Name (optional, used in shortened link) + + ) => + setShortName(e.target.value) + } + placeholder="Example: hemibrain-em-1" + type="text" + value={shortName} + /> + + ) : null} + {error ? ( + + {error} + + ) : null} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index 78db86d5..21da45fc 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -13,7 +13,8 @@ import { HiOutlineMenu, HiOutlineX, HiOutlineShare, - HiOutlineSun + HiOutlineSun, + HiOutlineEye } from 'react-icons/hi'; import { HiOutlineFolder, HiOutlineBriefcase } from 'react-icons/hi2'; import { TbBrandGithub } from 'react-icons/tb'; @@ -34,6 +35,11 @@ const LINKS = [ title: 'Data Links', href: '/links' }, + { + icon: HiOutlineEye, + title: 'NG Links', + href: '/nglinks' + }, { icon: HiOutlineBriefcase, title: 'Tasks', diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index d454a919..a367645a 100644 --- a/frontend/src/components/ui/Table/TableCard.tsx +++ b/frontend/src/components/ui/Table/TableCard.tsx @@ -53,7 +53,7 @@ type CellContextMenuData = { value: string; }; -// Extend TanStack Table's meta to include context menu handler +// Extend TanStack Table's meta to include context menu handler and column-level search declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface TableMeta { @@ -62,10 +62,15 @@ declare module '@tanstack/react-table' { data: CellContextMenuData ) => void; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + // Optional function to extract searchable values from a cell + // Used by globalFilterFn to allow columns to define custom search behavior + getSearchableValues?: (value: TValue, row: TData) => string[]; + } } -import type { PathCellValue } from './linksColumns'; -type DataType = 'data links' | 'tasks'; +type DataType = 'data links' | 'tasks' | 'NG links'; type TableProps = { readonly columns: ColumnDef[]; @@ -74,6 +79,7 @@ type TableProps = { readonly errorState: Error | unknown; readonly gridColsClass: string; readonly loadingState: boolean; + readonly headerActions?: ReactNode; }; function SortIcons({ @@ -150,6 +156,7 @@ const isISODate = (str: string): boolean => { }; // Custom global filter function that searches all columns +// Columns can define custom search behavior via meta.getSearchableValues const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { if (!filterValue) { return true; @@ -157,22 +164,7 @@ const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { const query = String(filterValue).toLowerCase(); - // Special handling for URLs: if query starts with "http", only check the key column - if (query.startsWith('http')) { - const keyCell = row - .getVisibleCells() - .find(cell => cell.column.id === 'sharing_key'); - if (keyCell) { - const keyValue = keyCell.getValue(); - if (keyValue !== null && keyValue !== undefined) { - const strKeyValue = String(keyValue).toLowerCase(); - return query.includes(strKeyValue); - } - } - return false; - } - - // For non-URL queries, search all columns except the name column + // Search all columns except the name column // NOTE: this needs to change if we allow custom sharing names // For now, the sharing name is always in the file path const rowValues = row.getVisibleCells().flatMap(cell => { @@ -186,20 +178,12 @@ const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { return ['']; } - // Special handling for path column with PathCellValue - if ( - typeof value === 'object' && - value !== null && - 'pathMap' in value && - 'displayPath' in value - ) { - const pathValue = value as PathCellValue; - // Return all three path types for searching - return [ - pathValue.pathMap.mac_path.toLowerCase(), - pathValue.pathMap.linux_path.toLowerCase(), - pathValue.pathMap.windows_path.toLowerCase() - ]; + // Use column meta if available for custom searchable values + const meta = cell.column.columnDef.meta; + if (meta?.getSearchableValues) { + return meta + .getSearchableValues(value, cell.row.original) + .map(v => v.toLowerCase()); } const strValue = String(value); @@ -218,13 +202,15 @@ function TableHeader({ globalFilter, setGlobalFilter, clearSearch, - inputRef + inputRef, + headerActions }: { readonly table: ReturnType; readonly globalFilter: string; readonly setGlobalFilter: (value: string) => void; readonly clearSearch: () => void; readonly inputRef: React.RefObject; + readonly headerActions?: ReactNode; }) { return (
@@ -305,6 +291,9 @@ function TableHeader({ ) : null}
+ {headerActions ? ( +
{headerActions}
+ ) : null} ); } @@ -331,7 +320,8 @@ function Table({ gridColsClass, loadingState, errorState, - dataType + dataType, + headerActions }: TableProps) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); @@ -420,6 +410,7 @@ function Table({ ({ gridColsClass, loadingState, errorState, - dataType + dataType, + headerActions }: TableProps) { return ( @@ -507,6 +499,7 @@ function TableCard({ dataType={dataType} errorState={errorState} gridColsClass={gridColsClass} + headerActions={headerActions} loadingState={loadingState} /> diff --git a/frontend/src/components/ui/Table/linksColumns.tsx b/frontend/src/components/ui/Table/linksColumns.tsx index c0ca1ab9..222848eb 100644 --- a/frontend/src/components/ui/Table/linksColumns.tsx +++ b/frontend/src/components/ui/Table/linksColumns.tsx @@ -238,7 +238,16 @@ export function useLinksColumns(): ColumnDef[] { const b = rowB.getValue(columnId) as PathCellValue; return a.displayPath.localeCompare(b.displayPath); }, - enableSorting: true + enableSorting: true, + meta: { + // Allow searching by URL and all path formats (linux, mac, windows) + getSearchableValues: (value: PathCellValue, row: ProxiedPath) => [ + row.url, + value.pathMap.mac_path, + value.pathMap.linux_path, + value.pathMap.windows_path + ] + } }, { accessorKey: 'created_at', diff --git a/frontend/src/components/ui/Table/ngLinksColumns.tsx b/frontend/src/components/ui/Table/ngLinksColumns.tsx new file mode 100644 index 00000000..82349ce3 --- /dev/null +++ b/frontend/src/components/ui/Table/ngLinksColumns.tsx @@ -0,0 +1,217 @@ +import { useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { Typography } from '@material-tailwind/react'; +import type { ColumnDef } from '@tanstack/react-table'; + +import type { NGLink } from '@/queries/ngLinkQueries'; +import { formatDateString } from '@/utils'; +import FgTooltip from '../widgets/FgTooltip'; +import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; +import type { MenuItem } from '@/components/ui/Menus/FgMenuItems'; +import toast from 'react-hot-toast'; +import { copyToClipboard } from '@/utils/copyText'; + +const TRIGGER_CLASSES = 'h-min max-w-full'; + +type NGLinkRowActionProps = { + item: NGLink; + onEdit: (item: NGLink) => void; + onDelete: (item: NGLink) => void; +}; + +function ActionsCell({ + item, + onEdit, + onDelete +}: { + readonly item: NGLink; + readonly onEdit: (item: NGLink) => void; + readonly onDelete: (item: NGLink) => void; +}) { + const menuItems: MenuItem[] = [ + { + name: 'Edit', + action: ({ item, onEdit }) => { + onEdit(item); + } + }, + { + name: 'Copy Neuroglancer Link', + action: async ({ item }) => { + const result = await copyToClipboard(item.neuroglancer_url); + if (result.success) { + toast.success('Neuroglancer link copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + } + }, + { + name: 'Copy JSON state link', + action: async ({ item }) => { + const result = await copyToClipboard(item.state_url); + if (result.success) { + toast.success('JSON state link copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + } + }, + { + name: 'Delete', + color: 'text-error', + action: ({ item, onDelete }) => { + onDelete(item); + } + } + ]; + + return ( +
+
e.stopPropagation()}> + + actionProps={{ item, onEdit, onDelete }} + menuItems={menuItems} + /> +
+
+ ); +} + +export function useNGLinksColumns( + onEdit: (item: NGLink) => void, + onDelete: (item: NGLink) => void +): ColumnDef[] { + return useMemo( + () => [ + { + accessorKey: 'short_name', + header: 'Name', + cell: ({ row, table }) => { + const item = row.original; + const label = item.short_name || item.short_key; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: label }); + }} + > + + + {label} + + +
+ ); + }, + sortingFn: (rowA, rowB) => { + const a = rowA.original.short_name || rowA.original.short_key; + const b = rowB.original.short_name || rowB.original.short_key; + return a.localeCompare(b); + }, + enableSorting: true + }, + { + accessorKey: 'neuroglancer_url', + header: 'Neuroglancer Short Link', + cell: ({ row, table }) => { + const item = row.original; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
) => { + e.preventDefault(); + onContextMenu?.(e, { value: item.neuroglancer_url }); + }} + > + + + {item.neuroglancer_url} + + +
+ ); + }, + enableSorting: false + }, + { + accessorKey: 'created_at', + header: 'Date Created', + cell: ({ cell, table }) => { + const formattedDate = formatDateString(cell.getValue() as string); + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: formattedDate }); + }} + > + + + {formattedDate} + + +
+ ); + }, + enableSorting: true + }, + { + accessorKey: 'short_key', + header: 'Key', + cell: ({ cell, getValue, table }) => { + const key = getValue() as string; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: key }); + }} + > + + + {key} + + +
+ ); + }, + enableSorting: true + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( + + ), + enableSorting: false + } + ], + [onEdit, onDelete] + ); +} diff --git a/frontend/src/contexts/NGLinkContext.tsx b/frontend/src/contexts/NGLinkContext.tsx new file mode 100644 index 00000000..515a1335 --- /dev/null +++ b/frontend/src/contexts/NGLinkContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +import { + useNGLinksQuery, + useCreateNGLinkMutation, + useUpdateNGLinkMutation, + useDeleteNGLinkMutation +} from '@/queries/ngLinkQueries'; + +type NGLinkContextType = { + allNGLinksQuery: ReturnType; + createNGLinkMutation: ReturnType; + updateNGLinkMutation: ReturnType; + deleteNGLinkMutation: ReturnType; +}; + +const NGLinkContext = createContext(null); + +export const useNGLinkContext = () => { + const context = useContext(NGLinkContext); + if (!context) { + throw new Error('useNGLinkContext must be used within a NGLinkProvider'); + } + return context; +}; + +export const NGLinkProvider = ({ + children +}: { + readonly children: ReactNode; +}) => { + const allNGLinksQuery = useNGLinksQuery(); + const createNGLinkMutation = useCreateNGLinkMutation(); + const updateNGLinkMutation = useUpdateNGLinkMutation(); + const deleteNGLinkMutation = useDeleteNGLinkMutation(); + + const value: NGLinkContextType = { + allNGLinksQuery, + createNGLinkMutation, + updateNGLinkMutation, + deleteNGLinkMutation + }; + + return ( + {children} + ); +}; + +export default NGLinkContext; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e7096f8c..153cfdce 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -15,6 +15,7 @@ import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { NotificationProvider } from '@/contexts/NotificationsContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { NGLinkProvider } from '@/contexts/NGLinkContext'; import FileglancerNavbar from '@/components/ui/Navbar/Navbar'; import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -67,13 +68,15 @@ export const MainLayout = () => { - - - - - - - + + + + + + + + + diff --git a/frontend/src/queries/ngLinkQueries.ts b/frontend/src/queries/ngLinkQueries.ts new file mode 100644 index 00000000..8d4b7e67 --- /dev/null +++ b/frontend/src/queries/ngLinkQueries.ts @@ -0,0 +1,161 @@ +import { + useQuery, + useMutation, + useQueryClient, + UseQueryResult, + UseMutationResult +} from '@tanstack/react-query'; + +import { sendFetchRequest, HTTPError } from '@/utils'; +import { toHttpError } from '@/utils/errorHandling'; + +export type NGLink = { + short_key: string; + short_name: string | null; + title: string | null; + created_at: string; + updated_at: string; + state_url: string; + neuroglancer_url: string; +}; + +type NGLinksResponse = { + links?: NGLink[]; +}; + +type NGLinkResponse = { + short_key: string; + short_name: string | null; + state_url: string; + neuroglancer_url: string; +}; + +type CreateNGLinkPayload = { + url?: string; + state?: Record; + url_base?: string; + short_name?: string; + short_key?: string; + title?: string; +}; + +type UpdateNGLinkPayload = { + short_key: string; + url: string; + title?: string; +}; + +export const ngLinkQueryKeys = { + all: ['ngLinks'] as const, + list: () => ['ngLinks', 'list'] as const +}; + +const fetchNGLinks = async (signal?: AbortSignal): Promise => { + try { + const response = await sendFetchRequest( + '/api/neuroglancer/nglinks', + 'GET', + undefined, + { signal } + ); + if (response.status === 404) { + return []; + } + if (!response.ok) { + throw await toHttpError(response); + } + const data = (await response.json()) as NGLinksResponse; + return data.links ?? []; + } catch (error) { + if (error instanceof HTTPError && error.responseCode === 404) { + return []; + } + throw error; + } +}; + +export function useNGLinksQuery(): UseQueryResult { + return useQuery({ + queryKey: ngLinkQueryKeys.list(), + queryFn: ({ signal }) => fetchNGLinks(signal) + }); +} + +export function useCreateNGLinkMutation(): UseMutationResult< + NGLinkResponse, + Error, + CreateNGLinkPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: CreateNGLinkPayload) => { + const response = await sendFetchRequest( + '/api/neuroglancer/nglinks', + 'POST', + payload + ); + if (!response.ok) { + throw await toHttpError(response); + } + return (await response.json()) as NGLinkResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ngLinkQueryKeys.all + }); + } + }); +} + +export function useUpdateNGLinkMutation(): UseMutationResult< + NGLinkResponse, + Error, + UpdateNGLinkPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: UpdateNGLinkPayload) => { + const response = await sendFetchRequest( + `/api/neuroglancer/nglinks/${encodeURIComponent(payload.short_key)}`, + 'PUT', + { url: payload.url, title: payload.title } + ); + if (!response.ok) { + throw await toHttpError(response); + } + return (await response.json()) as NGLinkResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ngLinkQueryKeys.all + }); + } + }); +} + +export function useDeleteNGLinkMutation(): UseMutationResult< + void, + Error, + string +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (shortKey: string) => { + const response = await sendFetchRequest( + `/api/neuroglancer/nglinks/${encodeURIComponent(shortKey)}`, + 'DELETE' + ); + if (!response.ok) { + throw await toHttpError(response); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ngLinkQueryKeys.all + }); + } + }); +} diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 4be6ab55..09d117ef 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,8 +1,10 @@ +import json import os import tempfile import shutil from datetime import datetime, timezone from unittest.mock import patch, MagicMock +from urllib.parse import quote import pytest from fastapi.testclient import TestClient @@ -190,6 +192,69 @@ def test_delete_preference(test_client): assert response.status_code == 404 +def test_neuroglancer_shortener(test_client): + """Test creating and retrieving a shortened Neuroglancer state""" + state = {"layers": [], "title": "Example"} + encoded_state = quote(json.dumps(state)) + url = f"https://neuroglancer-demo.appspot.com/#!{encoded_state}" + + # Test with short_name - URL should include both short_key and short_name + response = test_client.post( + "/api/neuroglancer/nglinks", + json={"url": url, "short_name": "example-view"} + ) + assert response.status_code == 200 + data = response.json() + assert "short_key" in data + assert data["short_name"] == "example-view" + assert "state_url" in data + assert "neuroglancer_url" in data + + short_key = data["short_key"] + short_name = data["short_name"] + assert data["state_url"].endswith(f"/ng/{short_key}/{short_name}") + assert data["neuroglancer_url"].startswith("https://neuroglancer-demo.appspot.com/#!") + + # Retrieving with both short_key and short_name should work + state_response = test_client.get(f"/ng/{short_key}/{short_name}") + assert state_response.status_code == 200 + assert state_response.json() == state + + # Retrieving with only short_key should fail (404) + state_response_simple = test_client.get(f"/ng/{short_key}") + assert state_response_simple.status_code == 404 + + list_response = test_client.get("/api/neuroglancer/nglinks") + assert list_response.status_code == 200 + list_data = list_response.json() + assert "links" in list_data + assert any(link["short_key"] == short_key for link in list_data["links"]) + + +def test_neuroglancer_shortener_no_name(test_client): + """Test creating a shortened Neuroglancer state without short_name""" + state = {"layers": []} + encoded_state = quote(json.dumps(state)) + url = f"https://neuroglancer-demo.appspot.com/#!{encoded_state}" + + response = test_client.post( + "/api/neuroglancer/nglinks", + json={"url": url} + ) + assert response.status_code == 200 + data = response.json() + assert "short_key" in data + assert data["short_name"] is None + + short_key = data["short_key"] + assert data["state_url"].endswith(f"/ng/{short_key}") + + # Retrieving with only short_key should work when no short_name was set + state_response = test_client.get(f"/ng/{short_key}") + assert state_response.status_code == 200 + assert state_response.json() == state + + def test_create_proxied_path(test_client, temp_dir): """Test creating a new proxied path""" path = "test_proxied_path" @@ -745,4 +810,3 @@ def test_delete_ticket_not_found(mock_delete, test_client): assert response.status_code == 404 data = response.json() assert "error" in data -