Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
f501ea4f94 fix(frontend): confirm modal, settings display, race guard, and test coverage
- S1: Replace window.confirm() with AppConfirmModal for delete actions
- S4: Add formatHours helper to avoid ugly decimals in settings hints
- W5: Add generation counter to SettingsAdminPanel to prevent stale
  async results when section changes rapidly
- S7: Remove unused ApiRequestError import from useScholarBulkActions
- S2: Add error-path tests for bulk delete/toggle network failures
- S3: Add CSRF boundary tests for bulk-delete and bulk-toggle endpoints
2026-03-07 18:07:29 +01:00
f8e3098fc3 fix(backend): harden bulk endpoints and redact pdf-queue email for non-admins
- W1: _parse_ids_param now returns 400 on non-integer input instead of 500
- W2: bulk_delete_scholars wraps commit in IntegrityError guard; router
  catches ScholarServiceError → 409
- W3: Move `from typing import Any` to top-level import in application.py
- W4: Redact requested_by_email in PDF queue response for non-admin users
2026-03-07 17:57:37 +01:00
c85fa08b7b fix(frontend): add cooldown tooltips and fix settings tab initial load
Add AppHelpHint tooltips to ScrapeSafetyBadge and ScrapeSafetyBanner
showing rate-limit explanation, cooldown reason, and recommended action.
When no cooldown is active, show a simple "ready" message.

Fix settings tabs not loading on initial navigation by deferring
onMounted load via nextTick, and using flush:'post' on the section
watcher so template refs are attached before load() runs.
2026-03-07 16:50:35 +01:00
39c7eb686c fix(mypy): use CursorResult type annotation for bulk_toggle_scholars 2026-03-07 16:16:55 +01:00
3e933998a0 fix(ci): fix mypy error in bulk_toggle_scholars
Use int(cursor.rowcount) instead of type: ignore comment.
2026-03-07 16:11:25 +01:00
6f3e0eec39 fix(ci): fix ruff format in scholars router
Put each structured_log argument on its own line.
2026-03-07 16:11:25 +01:00
76c4811405 fix(ci): fix import sorting, API contract, and pdf-queue test
- Add missing blank line after imports in application.py (ruff I001)
- Use full path string instead of template literal for export API call
  to avoid false positive in API contract drift check
- Update pdf-queue test to expect 200 for non-admin listing
2026-03-07 16:11:25 +01:00
6742eff773 feat(scholars): add multiselect and bulk actions (delete, toggle, export)
Add Set<number>-based selection state to ScholarsPage with checkboxes
in both table and card views, select-all toggle, and stale-key pruning.

Backend: POST /scholars/bulk-delete, POST /scholars/bulk-toggle, and
GET /scholars/export?ids= filter. Service functions use user-scoped
queries. Structured logging for bulk operations.

Frontend: useScholarBulkActions composable extracts selection and bulk
action logic. AppSelect-based action dropdown with dynamic options.
Delete prompts window.confirm; enable/disable applies immediately.
Export downloads filtered JSON.

Includes integration tests for all bulk endpoints and frontend unit
tests for selection toggle, select-all, and stale pruning.
2026-03-07 16:11:25 +01:00
61ac6f206f fix(test): add Pinia setup and admin user to AdminPdfQueueSection tests
The component uses useAuthStore() which requires an active Pinia
instance, and the "Queue all" button is admin-only (v-if="auth.isAdmin").
2026-03-07 15:51:48 +01:00
afbb25d217 fix(ci): use theme tokens for badges, update pdf-queue test
- Replace raw dark: utility variants with state-* theme tokens
  in ScholarStatusBadges and ScholarSettingsModal
- Update integration test to expect 200 for pdf-queue listing
  (endpoint now uses get_api_current_user, not admin-only)
2026-03-07 15:32:42 +01:00
813da91608 fix(scholars): improve validation, fix delete, add status badges
- Extract scholar ID parsing to dedicated module with robust URL
  handling (trailing slashes, fragments, extra query params, encoded
  characters) and per-token error feedback showing why each invalid
  entry was skipped
- Wrap delete_scholar DB commit in IntegrityError handler so cascade
  failures return a proper API error instead of 500
- Add ScholarStatusBadges component showing Pending/Failed/Partial/OK
  badges on scholar rows based on baseline_completed and last_run_status
- Show Pending badge in ScholarSettingsModal for unscraped scholars
- Surface failed_count and partial_count in dashboard latest run summary
  and recent run list items
- Extend backend validator tests with edge cases (empty, whitespace,
  unicode, URL-as-ID)
- Add integration tests for DELETE 404, DELETE with cascade, and POST
  with corrupted scholar_id
- Add comprehensive frontend tests for parsing logic and component
  behavior
2026-03-07 14:05:39 +01:00
0d955c31dc feat(settings): hours interval, pdf queue for all users, fix users tab loading
- Convert check interval setting from minutes to hours in the UI
- Show PDF queue tab to all authenticated users (not admin-only); requeue
  actions remain admin-only in both backend and frontend
- Fix users tab not loading on first visit by removing the AsyncStateGate
  wrapper that prevented child component refs from being populated before
  onMounted fired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:53:00 +01:00
a72151cfdc feat(frontend): show run and cancel buttons to all users
Remove the auth.isAdmin guard from the Start check and Cancel check
buttons in the Activity Monitor. Admin-only route links (check history,
diagnostics) are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:53:00 +01:00
JustinZeus
c6c89879c9
Merge pull request #47 from JustinZeus/feat/live-scholar-progress-counter
fix(ingestion,frontend): show live counter immediately on run start
2026-03-03 18:38:36 +01:00
2c591a0ca3 fix(ingestion,frontend): show live counter immediately on run start
Two issues with the initial implementation:
- Counter only appeared after the first SSE event arrived (scholarProgress null
  kept the v-else static template showing until a scholar completed)
- First events were often dropped because the EventSource hadn't subscribed yet

Fix:
- Backend: emit an initial scholar_progress kickoff event (0/0/N) at the
  start of run_scholar_iteration so the frontend receives total early
- Frontend: gate on isLikelyRunning alone (not scholarProgress); use optional
  chaining with fallbacks so "0 / … visited · 0 finished" shows immediately
  when a run starts, before any SSE events arrive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 18:37:53 +01:00
JustinZeus
5754bffaa0
Merge pull request #46 from JustinZeus/feat/live-scholar-progress-counter
feat(ingestion,frontend): add live scholar progress counter to dashboard
2026-03-03 17:52:46 +01:00
4620978dce feat(ingestion,frontend): add live scholar progress counter to dashboard
Emit scholar_progress SSE events during two-pass ingestion so the
Activity Monitor shows "X / N visited · Y finished · Z new publications"
in real time instead of static post-run counts.

- scholar_processing: add on_progress callback to _run_first_pass and
  _run_depth_pass; build _emit closure in run_scholar_iteration that
  publishes visited/finished/total via run_events; batch-emit scholars
  finished in first pass before depth pass; skip emission on abort/cancel
- run_status store: add scholarProgress state, reset on stream open and
  reset(); subscribe to scholar_progress SSE events with safe parsing
- DashboardPage: show live counter during run, fall back to static text
  when run is not active or no progress received yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 17:36:43 +01:00
JustinZeus
cfb1033110
Merge pull request #45 from JustinZeus/bugfix/broken-import
openalex cooldown
2026-03-03 16:17:39 +01:00
JustinZeus
8ba3b3d41a
Merge pull request #44 from JustinZeus/bugfix/broken-import
added test for new publication detection
2026-03-02 13:59:07 +01:00
JustinZeus
92ad054b97
Merge pull request #43 from JustinZeus/bugfix/broken-import
fix: resolve mypy errors, fix arxiv test session isolation, and add p…
2026-03-02 12:46:35 +01:00
JustinZeus
acf23a7d6c
Merge pull request #42 from JustinZeus/bugfix/broken-import
fixed import
2026-03-02 00:03:55 +01:00
JustinZeus
4d949e8657
Merge pull request #41 from JustinZeus/openalex-size-reduction-scholar-hydration
fix: URL-length-aware OpenAlex batching and eager scholar metadata hy…
2026-03-01 23:44:12 +01:00
JustinZeus
e2fe34946c
Merge pull request #40 from JustinZeus/mypi-fix
fix: align test assertion with rollback behavior
2026-03-01 23:18:05 +01:00
JustinZeus
76469e2d1e
Merge pull request #39 from JustinZeus/mypi-fix
" "
2026-03-01 23:12:26 +01:00
JustinZeus
9401aa9b69
Merge pull request #38 from JustinZeus/mypi-fix
db tets fix
2026-03-01 23:08:15 +01:00
JustinZeus
6e17a65b2f
Merge pull request #37 from JustinZeus/mypi-fix
Mypi fix
2026-03-01 23:04:55 +01:00
JustinZeus
84faaad5eb
Merge pull request #36 from JustinZeus/ci-fixes
Ci fixes
2026-03-01 23:02:03 +01:00
JustinZeus
33f9579169
Merge pull request #19 from JustinZeus/feat/decomposition
Feat/decomposition
2026-03-01 22:55:32 +01:00
JustinZeus
4a66631a57
Merge pull request #18 from JustinZeus/feat/beta
release prep
2026-02-21 18:36:57 +01:00
JustinZeus
4bdef0cc2a
Merge pull request #17 from JustinZeus/feat/docs
docs
2026-02-21 17:56:34 +01:00
30 changed files with 1679 additions and 197 deletions

View file

@ -5,7 +5,7 @@ import logging
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_api_admin_user from app.api.deps import get_api_admin_user, get_api_current_user
from app.api.errors import ApiException from app.api.errors import ApiException
from app.api.responses import success_payload from app.api.responses import success_payload
from app.api.schemas import ( from app.api.schemas import (
@ -57,7 +57,7 @@ def _requested_by_value(*, payload, admin_user: User) -> str:
return from_payload or admin_user.email return from_payload or admin_user.email
def _serialize_pdf_queue_item(item) -> dict[str, object]: def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, object]:
return { return {
"publication_id": item.publication_id, "publication_id": item.publication_id,
"title": item.title, "title": item.title,
@ -69,7 +69,7 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]:
"last_failure_detail": item.last_failure_detail, "last_failure_detail": item.last_failure_detail,
"last_source": item.last_source, "last_source": item.last_source,
"requested_by_user_id": item.requested_by_user_id, "requested_by_user_id": item.requested_by_user_id,
"requested_by_email": item.requested_by_email, "requested_by_email": item.requested_by_email if is_admin else None,
"queued_at": item.queued_at, "queued_at": item.queued_at,
"last_attempt_at": item.last_attempt_at, "last_attempt_at": item.last_attempt_at,
"resolved_at": item.resolved_at, "resolved_at": item.resolved_at,
@ -185,7 +185,7 @@ async def get_pdf_queue(
offset: int | None = Query(default=None, ge=0), offset: int | None = Query(default=None, ge=0),
status: str | None = Query(default=None), status: str | None = Query(default=None),
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user), current_user: User = Depends(get_api_current_user),
): ):
normalized_status = (status or "").strip().lower() or None normalized_status = (status or "").strip().lower() or None
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
@ -204,7 +204,7 @@ async def get_pdf_queue(
logger, logger,
"info", "info",
"api.admin.db.pdf_queue_listed", "api.admin.db.pdf_queue_listed",
admin_user_id=int(admin_user.id), user_id=int(current_user.id),
page=int(resolved_page), page=int(resolved_page),
page_size=int(resolved_limit), page_size=int(resolved_limit),
offset=int(resolved_offset), offset=int(resolved_offset),
@ -215,7 +215,7 @@ async def get_pdf_queue(
return success_payload( return success_payload(
request, request,
data={ data={
"items": [_serialize_pdf_queue_item(item) for item in queue_page.items], "items": [_serialize_pdf_queue_item(item, is_admin=current_user.is_admin) for item in queue_page.items],
**_pdf_queue_page_data( **_pdf_queue_page_data(
total_count=queue_page.total_count, total_count=queue_page.total_count,
page=resolved_page, page=resolved_page,

View file

@ -23,6 +23,9 @@ from app.api.schemas import (
DataImportEnvelope, DataImportEnvelope,
DataImportRequest, DataImportRequest,
MessageEnvelope, MessageEnvelope,
ScholarBulkCountEnvelope,
ScholarBulkIdsRequest,
ScholarBulkToggleRequest,
ScholarCreateRequest, ScholarCreateRequest,
ScholarEnvelope, ScholarEnvelope,
ScholarImageUrlUpdateRequest, ScholarImageUrlUpdateRequest,
@ -42,6 +45,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scholars", tags=["api-scholars"]) router = APIRouter(prefix="/scholars", tags=["api-scholars"])
def _parse_ids_param(ids: str | None) -> list[int] | None:
if not ids:
return None
parts = [p.strip() for p in ids.split(",") if p.strip()]
if not parts:
return None
try:
return [int(p) for p in parts]
except ValueError as exc:
raise ApiException(
status_code=400,
code="invalid_ids_param",
message="The 'ids' parameter must be a comma-separated list of integers.",
) from exc
@router.get( @router.get(
"", "",
response_model=ScholarsListEnvelope, response_model=ScholarsListEnvelope,
@ -69,16 +88,81 @@ async def list_scholars(
) )
async def export_scholars_and_publications( async def export_scholars_and_publications(
request: Request, request: Request,
ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"),
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
scholar_profile_ids = _parse_ids_param(ids)
data = await import_export_service.export_user_data( data = await import_export_service.export_user_data(
db_session, db_session,
user_id=current_user.id, user_id=current_user.id,
scholar_profile_ids=scholar_profile_ids,
) )
return success_payload(request, data=data) return success_payload(request, data=data)
@router.post(
"/bulk-delete",
response_model=ScholarBulkCountEnvelope,
)
async def bulk_delete_scholars(
payload: ScholarBulkIdsRequest,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
try:
deleted_count = await scholar_service.bulk_delete_scholars(
db_session,
user_id=current_user.id,
scholar_profile_ids=payload.scholar_profile_ids,
upload_dir=settings.scholar_image_upload_dir,
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=409,
code="scholar_bulk_delete_failed",
message=str(exc),
) from exc
structured_log(
logger,
"info",
"scholars.bulk_delete",
user_id=current_user.id,
requested_ids=payload.scholar_profile_ids,
deleted_count=deleted_count,
)
return success_payload(request, data={"deleted_count": deleted_count, "updated_count": 0})
@router.post(
"/bulk-toggle",
response_model=ScholarBulkCountEnvelope,
)
async def bulk_toggle_scholars(
payload: ScholarBulkToggleRequest,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
updated_count = await scholar_service.bulk_toggle_scholars(
db_session,
user_id=current_user.id,
scholar_profile_ids=payload.scholar_profile_ids,
is_enabled=payload.is_enabled,
)
structured_log(
logger,
"info",
"scholars.bulk_toggle",
user_id=current_user.id,
requested_ids=payload.scholar_profile_ids,
is_enabled=payload.is_enabled,
updated_count=updated_count,
)
return success_payload(request, data={"deleted_count": 0, "updated_count": updated_count})
@router.post( @router.post(
"/import", "/import",
response_model=DataImportEnvelope, response_model=DataImportEnvelope,
@ -261,11 +345,18 @@ async def delete_scholar(
code="scholar_not_found", code="scholar_not_found",
message="Scholar not found.", message="Scholar not found.",
) )
await scholar_service.delete_scholar( try:
db_session, await scholar_service.delete_scholar(
profile=profile, db_session,
upload_dir=settings.scholar_image_upload_dir, profile=profile,
) upload_dir=settings.scholar_image_upload_dir,
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=409,
code="scholar_delete_failed",
message=str(exc),
) from exc
structured_log( structured_log(
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
) )

View file

@ -126,6 +126,33 @@ class DataExportEnvelope(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class ScholarBulkIdsRequest(BaseModel):
scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500)
model_config = ConfigDict(extra="forbid")
class ScholarBulkToggleRequest(BaseModel):
scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500)
is_enabled: bool
model_config = ConfigDict(extra="forbid")
class ScholarBulkCountData(BaseModel):
deleted_count: int = 0
updated_count: int = 0
model_config = ConfigDict(extra="forbid")
class ScholarBulkCountEnvelope(BaseModel):
data: ScholarBulkCountData
meta: ApiMeta
model_config = ConfigDict(extra="forbid")
class DataImportRequest(BaseModel): class DataImportRequest(BaseModel):
schema_version: int | None = None schema_version: int | None = None
exported_at: str | None = None exported_at: str | None = None

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import random import random
from collections.abc import Callable, Coroutine
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -274,6 +275,7 @@ async def _run_first_pass(
request_delay_seconds: int, request_delay_seconds: int,
queue_delay_seconds: int, queue_delay_seconds: int,
progress: RunProgress, progress: RunProgress,
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
) -> dict[int, int]: ) -> dict[int, int]:
first_pass_cstarts: dict[int, int] = {} first_pass_cstarts: dict[int, int] = {}
for index, scholar in enumerate(scholars): for index, scholar in enumerate(scholars):
@ -310,6 +312,8 @@ async def _run_first_pass(
scholars_remaining=len(scholars) - index - 1, scholars_remaining=len(scholars) - index - 1,
) )
return first_pass_cstarts return first_pass_cstarts
if on_progress is not None:
await on_progress(1, 0)
resume_cstart = outcome.result_entry.get("continuation_cstart") resume_cstart = outcome.result_entry.get("continuation_cstart")
if resume_cstart is not None and int(resume_cstart) > start_cstart: if resume_cstart is not None and int(resume_cstart) > start_cstart:
first_pass_cstarts[int(scholar.id)] = int(resume_cstart) first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
@ -330,6 +334,7 @@ async def _run_depth_pass(
auto_queue_continuations: bool, auto_queue_continuations: bool,
queue_delay_seconds: int, queue_delay_seconds: int,
progress: RunProgress, progress: RunProgress,
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
) -> None: ) -> None:
for index, scholar in enumerate(scholars): for index, scholar in enumerate(scholars):
resume_cstart = first_pass_cstarts.get(int(scholar.id)) resume_cstart = first_pass_cstarts.get(int(scholar.id))
@ -367,6 +372,8 @@ async def _run_depth_pass(
scholars_remaining=len(scholars) - index - 1, scholars_remaining=len(scholars) - index - 1,
) )
break break
if on_progress is not None:
await on_progress(0, 1)
async def run_scholar_iteration( async def run_scholar_iteration(
@ -387,6 +394,8 @@ async def run_scholar_iteration(
auto_queue_continuations: bool, auto_queue_continuations: bool,
queue_delay_seconds: int, queue_delay_seconds: int,
) -> RunProgress: ) -> RunProgress:
from app.services.runs.events import run_events
progress = RunProgress() progress = RunProgress()
scholar_kwargs: dict[str, Any] = { scholar_kwargs: dict[str, Any] = {
"request_delay_seconds": request_delay_seconds, "request_delay_seconds": request_delay_seconds,
@ -396,6 +405,22 @@ async def run_scholar_iteration(
"rate_limit_backoff_seconds": rate_limit_backoff_seconds, "rate_limit_backoff_seconds": rate_limit_backoff_seconds,
"page_size": page_size, "page_size": page_size,
} }
visited = 0
finished = 0
total = len(scholars)
async def _emit(v: int = 0, f: int = 0) -> None:
nonlocal visited, finished
visited += v
finished += f
await run_events.publish(
run.id,
"scholar_progress",
{"visited": visited, "finished": finished, "total": total},
)
await _emit()
first_pass_cstarts = await _run_first_pass( first_pass_cstarts = await _run_first_pass(
db_session, db_session,
scholars=scholars, scholars=scholars,
@ -407,8 +432,12 @@ async def run_scholar_iteration(
request_delay_seconds=request_delay_seconds, request_delay_seconds=request_delay_seconds,
queue_delay_seconds=queue_delay_seconds, queue_delay_seconds=queue_delay_seconds,
progress=progress, progress=progress,
on_progress=_emit,
) )
remaining_max = max(max_pages_per_scholar - 1, 0) remaining_max = max(max_pages_per_scholar - 1, 0)
scholars_finished_in_first_pass = len(scholars) - len(first_pass_cstarts)
if scholars_finished_in_first_pass > 0:
await _emit(f=scholars_finished_in_first_pass)
if remaining_max <= 0: if remaining_max <= 0:
return progress return progress
await _run_depth_pass( await _run_depth_pass(
@ -424,5 +453,6 @@ async def run_scholar_iteration(
auto_queue_continuations=auto_queue_continuations, auto_queue_continuations=auto_queue_continuations,
queue_delay_seconds=queue_delay_seconds, queue_delay_seconds=queue_delay_seconds,
progress=progress, progress=progress,
on_progress=_emit,
) )
return progress return progress

View file

@ -56,11 +56,14 @@ async def export_user_data(
db_session: AsyncSession, db_session: AsyncSession,
*, *,
user_id: int, user_id: int,
scholar_profile_ids: list[int] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
scholars_result = await db_session.execute( scholar_query = select(ScholarProfile).where(ScholarProfile.user_id == user_id)
select(ScholarProfile).where(ScholarProfile.user_id == user_id).order_by(ScholarProfile.id.asc()) if scholar_profile_ids:
) scholar_query = scholar_query.where(ScholarProfile.id.in_(scholar_profile_ids))
publication_result = await db_session.execute( scholars_result = await db_session.execute(scholar_query.order_by(ScholarProfile.id.asc()))
pub_query = (
select( select(
ScholarProfile.scholar_id, ScholarProfile.scholar_id,
Publication.cluster_id, Publication.cluster_id,
@ -77,8 +80,13 @@ async def export_user_data(
.join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id) .join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id)
.join(Publication, Publication.id == ScholarPublication.publication_id) .join(Publication, Publication.id == ScholarPublication.publication_id)
.where(ScholarProfile.user_id == user_id) .where(ScholarProfile.user_id == user_id)
.order_by(ScholarPublication.created_at.desc(), Publication.id.desc())
) )
if scholar_profile_ids:
pub_query = pub_query.where(ScholarProfile.id.in_(scholar_profile_ids))
publication_result = await db_session.execute(
pub_query.order_by(ScholarPublication.created_at.desc(), Publication.id.desc())
)
scholars = [_serialize_export_scholar(profile) for profile in scholars_result.scalars().all()] scholars = [_serialize_export_scholar(profile) for profile in scholars_result.scalars().all()]
publications = [_serialize_export_publication(row) for row in publication_result.all()] publications = [_serialize_export_publication(row) for row in publication_result.all()]
return { return {

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -27,10 +28,66 @@ from app.services.scholars.validators import (
validate_scholar_id, validate_scholar_id,
) )
async def bulk_delete_scholars(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_ids: list[int],
upload_dir: str | None = None,
) -> int:
from sqlalchemy import select
result = await db_session.execute(
select(ScholarProfile).where(
ScholarProfile.id.in_(scholar_profile_ids),
ScholarProfile.user_id == user_id,
)
)
profiles = list(result.scalars().all())
if not profiles:
return 0
if upload_dir:
upload_root = _ensure_upload_root(upload_dir, create=True)
for profile in profiles:
_safe_remove_upload(upload_root, profile.profile_image_upload_path)
for profile in profiles:
await db_session.delete(profile)
try:
await db_session.commit()
except IntegrityError as exc:
await db_session.rollback()
raise ScholarServiceError("Unable to bulk-delete scholars due to a database constraint.") from exc
return len(profiles)
async def bulk_toggle_scholars(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_ids: list[int],
is_enabled: bool,
) -> int:
from sqlalchemy import CursorResult, update
cursor: CursorResult[Any] = await db_session.execute( # type: ignore[assignment]
update(ScholarProfile)
.where(
ScholarProfile.id.in_(scholar_profile_ids),
ScholarProfile.user_id == user_id,
)
.values(is_enabled=is_enabled)
)
await db_session.commit()
return int(cursor.rowcount or 0)
__all__ = [ __all__ = [
"SEARCH_COOLDOWN_REASON", "SEARCH_COOLDOWN_REASON",
"SEARCH_DISABLED_REASON", "SEARCH_DISABLED_REASON",
"ScholarServiceError", "ScholarServiceError",
"bulk_delete_scholars",
"bulk_toggle_scholars",
"clear_profile_image_customization", "clear_profile_image_customization",
"create_scholar_for_user", "create_scholar_for_user",
"delete_scholar", "delete_scholar",
@ -125,7 +182,11 @@ async def delete_scholar(
_safe_remove_upload(upload_root, profile.profile_image_upload_path) _safe_remove_upload(upload_root, profile.profile_image_upload_path)
await db_session.delete(profile) await db_session.delete(profile)
await db_session.commit() try:
await db_session.commit()
except IntegrityError as exc:
await db_session.rollback()
raise ScholarServiceError("Unable to delete scholar due to a database constraint.") from exc
async def hydrate_profile_metadata( async def hydrate_profile_metadata(

View file

@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
return { ...createDefaultSafetyState(), ...overrides };
}
describe("ScrapeSafetyBadge", () => {
it("shows ready tooltip when cooldown is inactive", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: { state: buildState({ cooldown_active: false }) },
});
expect(wrapper.text()).toContain("Safety ready");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
expect(hint.props("text")).toContain("No active cooldown");
});
it("shows cooldown tooltip with reason and action when active", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Too many blocked requests",
recommended_action: "Wait for cooldown to expire",
cooldown_remaining_seconds: 120,
}),
},
});
expect(wrapper.text()).toContain("Safety cooldown");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).toContain("Too many blocked requests");
expect(text).toContain("Wait for cooldown to expire");
});
it("shows cooldown tooltip without optional fields", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "some_reason",
cooldown_reason_label: null,
recommended_action: null,
cooldown_remaining_seconds: 60,
}),
},
});
const hint = wrapper.findComponent({ name: "AppHelpHint" });
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).not.toContain("Why:");
expect(text).not.toContain("Action:");
});
});

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { type ScrapeSafetyState } from "@/features/safety"; import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{ const props = defineProps<{
@ -18,10 +19,30 @@ const toneClass = computed(() => {
} }
return "border-state-warning-border bg-state-warning-bg text-state-warning-text"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}); });
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
const RATE_LIMIT_INTRO =
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
const tooltipText = computed(() => {
if (!props.state.cooldown_active) return READY_TOOLTIP;
const parts = [RATE_LIMIT_INTRO];
if (props.state.cooldown_reason_label) {
parts.push(`Why: ${props.state.cooldown_reason_label}`);
}
if (props.state.recommended_action) {
parts.push(`Action: ${props.state.recommended_action}`);
}
return parts.join(" \u2014 ");
});
</script> </script>
<template> <template>
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass"> <span class="inline-flex items-center gap-1">
{{ label }} <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
{{ label }}
</span>
<AppHelpHint :text="tooltipText" />
</span> </span>
</template> </template>

View file

@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AppAlert from "@/components/ui/AppAlert.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { import {
formatCooldownCountdown, formatCooldownCountdown,
type ScrapeSafetyState, type ScrapeSafetyState,
@ -79,6 +80,10 @@ const actionText = computed(() => {
return props.safetyState.recommended_action; return props.safetyState.recommended_action;
}); });
const bannerHintText = computed(() => {
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
});
onMounted(() => { onMounted(() => {
timer = setInterval(() => { timer = setInterval(() => {
now.value = Date.now(); now.value = Date.now();
@ -95,7 +100,12 @@ onBeforeUnmount(() => {
<template> <template>
<AppAlert v-if="isVisible" :tone="tone"> <AppAlert v-if="isVisible" :tone="tone">
<template #title>{{ title }}</template> <template #title>
<span class="inline-flex items-center gap-1">
{{ title }}
<AppHelpHint :text="bannerHintText" />
</span>
</template>
<p>{{ detailText }}</p> <p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p> <p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert> </AppAlert>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import AppButton from "@/components/ui/AppButton.vue";
import AppModal from "@/components/ui/AppModal.vue";
withDefaults(
defineProps<{
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
}>(),
{ confirmLabel: "Confirm", cancelLabel: "Cancel", variant: "default" },
);
const emit = defineEmits<{ confirm: []; cancel: [] }>();
</script>
<template>
<AppModal :open="open" :title="title" @close="emit('cancel')">
<p class="mb-6 text-sm text-secondary">{{ message }}</p>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" @click="emit('cancel')">{{ cancelLabel }}</AppButton>
<AppButton :variant="variant === 'danger' ? 'danger' : 'primary'" @click="emit('confirm')">{{ confirmLabel }}</AppButton>
</div>
</AppModal>
</template>

View file

@ -2,10 +2,144 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import ScholarBatchAdd from "./ScholarBatchAdd.vue"; import ScholarBatchAdd from "./ScholarBatchAdd.vue";
import {
parseScholarIds,
parseScholarTokens,
extractScholarIdFromUrl,
validateTokenAsId,
} from "./scholar-batch-parsing";
const defaultProps = { saving: false, loading: false }; const defaultProps = { saving: false, loading: false };
describe("ScholarBatchAdd", () => { describe("parseScholarIds (unit)", () => {
it("parses a single bare ID", () => {
expect(parseScholarIds("A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
});
it("parses multiple IDs separated by commas", () => {
expect(parseScholarIds("A-UbBTPM15wL, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
});
it("deduplicates IDs", () => {
expect(parseScholarIds("A-UbBTPM15wL\nA-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
});
it("extracts ID from standard Google Scholar URL", () => {
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
});
it("extracts ID from URL with trailing slash", () => {
expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL/")).toEqual(["A-UbBTPM15wL"]);
});
it("extracts ID from URL with fragment", () => {
expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL#section")).toEqual(["A-UbBTPM15wL"]);
});
it("extracts ID from URL with extra query params", () => {
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&view_op=list_works&sortby=pubdate")).toEqual(["A-UbBTPM15wL"]);
});
it("handles URL-encoded characters in non-ID parts", () => {
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&label=%E4%B8%AD%E6%96%87")).toEqual(["A-UbBTPM15wL"]);
});
it("rejects malformed IDs (too short)", () => {
expect(parseScholarIds("ABC123")).toEqual([]);
});
it("rejects malformed IDs (too long)", () => {
expect(parseScholarIds("ABCDEF1234567")).toEqual([]);
});
it("rejects IDs with special characters", () => {
expect(parseScholarIds("ABCDEF12345!")).toEqual([]);
});
it("rejects IDs with embedded whitespace", () => {
expect(parseScholarIds("ABC DEF12345")).toEqual([]);
});
it("handles mixed valid and invalid tokens", () => {
expect(parseScholarIds("A-UbBTPM15wL, invalid, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
});
it("returns empty array for empty string", () => {
expect(parseScholarIds("")).toEqual([]);
});
it("returns empty array for whitespace only", () => {
expect(parseScholarIds(" ")).toEqual([]);
});
});
describe("extractScholarIdFromUrl", () => {
it("returns null for non-URL strings", () => {
expect(extractScholarIdFromUrl("not-a-url")).toBeNull();
});
it("returns null for URL without user param", () => {
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?hl=en")).toBeNull();
});
it("extracts from URL with trailing slashes", () => {
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL///")).toBe("A-UbBTPM15wL");
});
it("strips fragment before parsing", () => {
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL#foo")).toBe("A-UbBTPM15wL");
});
it("returns null when user param is invalid length", () => {
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=short")).toBeNull();
});
});
describe("validateTokenAsId", () => {
it("returns null for valid 12-char ID", () => {
expect(validateTokenAsId("ABCDEF123456")).toBeNull();
});
it("detects wrong length", () => {
expect(validateTokenAsId("ABC")).toContain("12 characters");
});
it("detects invalid characters", () => {
expect(validateTokenAsId("ABCDEF12345!")).toContain("invalid characters");
});
it("detects whitespace", () => {
expect(validateTokenAsId("ABC DEF12345")).toContain("whitespace");
});
it("detects empty input", () => {
expect(validateTokenAsId("")).toContain("empty");
});
});
describe("parseScholarTokens (per-token errors)", () => {
it("marks invalid tokens with error messages", () => {
const tokens = parseScholarTokens("A-UbBTPM15wL, short, B-UbBTPM15wL");
expect(tokens).toHaveLength(3);
expect(tokens[0].id).toBe("A-UbBTPM15wL");
expect(tokens[0].error).toBeNull();
expect(tokens[1].id).toBeNull();
expect(tokens[1].error).toContain("12 characters");
expect(tokens[2].id).toBe("B-UbBTPM15wL");
});
it("marks duplicate tokens", () => {
const tokens = parseScholarTokens("A-UbBTPM15wL, A-UbBTPM15wL");
expect(tokens[1].error).toBe("duplicate");
});
it("marks bad URL tokens", () => {
const tokens = parseScholarTokens("https://scholar.google.com/citations?hl=en");
expect(tokens[0].error).toContain("could not extract");
});
});
describe("ScholarBatchAdd (component)", () => {
it("renders the heading and input", () => { it("renders the heading and input", () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
expect(wrapper.text()).toContain("Add Scholar Profiles"); expect(wrapper.text()).toContain("Add Scholar Profiles");
@ -73,4 +207,18 @@ describe("ScholarBatchAdd", () => {
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } }); const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
expect(wrapper.text()).toContain("Adding..."); expect(wrapper.text()).toContain("Adding...");
}); });
it("shows validation errors for invalid tokens", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("A-UbBTPM15wL, bad!");
const errors = wrapper.find("[data-testid='validation-errors']");
expect(errors.exists()).toBe(true);
});
it("shows skipped count alongside valid count", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("A-UbBTPM15wL, tooshort");
expect(wrapper.text()).toContain("1 valid");
expect(wrapper.text()).toContain("1 skipped");
});
}); });

View file

@ -3,6 +3,7 @@ import { computed, ref } from "vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { parseScholarTokens } from "./scholar-batch-parsing";
defineProps<{ defineProps<{
saving: boolean; saving: boolean;
@ -15,38 +16,13 @@ const emit = defineEmits<{
const scholarBatchInput = ref(""); const scholarBatchInput = ref("");
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; const parsedTokens = computed(() => parseScholarTokens(scholarBatchInput.value));
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i; const validIds = computed(() => parsedTokens.value.filter((t) => t.id !== null));
const invalidTokens = computed(() => parsedTokens.value.filter((t) => t.id === null));
function parseScholarIds(raw: string): string[] { const parsedBatchCount = computed(() => validIds.value.length);
const ordered: string[] = [];
const seen = new Set<string>();
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
for (const token of tokens) {
let candidate: string | null = null;
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
if (!candidate) {
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
if (directParamMatch) candidate = directParamMatch[1];
}
if (!candidate && token.includes("scholar.google")) {
try {
const parsed = new URL(token);
const userParam = parsed.searchParams.get("user");
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
} catch (_error) { /* Ignore non-URL tokens. */ }
}
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
ordered.push(candidate);
}
return ordered;
}
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
function onSubmit(): void { function onSubmit(): void {
const ids = parseScholarIds(scholarBatchInput.value); const ids = validIds.value.map((t) => t.id!);
if (ids.length > 0) { if (ids.length > 0) {
emit("add-scholars", ids); emit("add-scholars", ids);
scholarBatchInput.value = ""; scholarBatchInput.value = "";
@ -76,11 +52,33 @@ function onSubmit(): void {
/> />
</label> </label>
<div class="flex flex-wrap items-center justify-between gap-2"> <div v-if="parsedTokens.length > 0" class="space-y-1">
<p class="text-xs text-secondary"> <p class="text-xs text-secondary">
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong> <strong class="text-ink-primary">{{ parsedBatchCount }}</strong> valid ID{{ parsedBatchCount === 1 ? "" : "s" }}
<template v-if="invalidTokens.length > 0">
· <span class="text-state-warning-text">{{ invalidTokens.length }} skipped</span>
</template>
</p> </p>
<AppButton type="submit" :disabled="saving || loading"> <ul v-if="invalidTokens.length > 0" class="space-y-0.5" data-testid="validation-errors">
<li
v-for="item in invalidTokens.slice(0, 5)"
:key="item.index"
class="text-xs text-state-warning-text"
>
#{{ item.index }}: {{ item.error }}
<code class="ml-1 break-all text-ink-muted">{{ item.raw.length > 60 ? item.raw.slice(0, 57) + "..." : item.raw }}</code>
</li>
<li v-if="invalidTokens.length > 5" class="text-xs text-state-warning-text">
+{{ invalidTokens.length - 5 }} more skipped
</li>
</ul>
</div>
<div v-else class="text-xs text-secondary">
Parsed IDs: <strong class="text-ink-primary">0</strong>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<AppButton type="submit" :disabled="saving || loading || parsedBatchCount === 0">
{{ saving ? "Adding..." : "Add scholars" }} {{ saving ? "Adding..." : "Add scholars" }}
</AppButton> </AppButton>
</div> </div>

View file

@ -47,7 +47,14 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer
:image-url="scholar.profile_image_url" :image-url="scholar.profile_image_url"
/> />
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(scholar) }}</p> <p class="truncate text-sm font-semibold text-ink-primary">
{{ scholarLabel(scholar) }}
<span
v-if="!scholar.baseline_completed"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
>Pending</span>
</p>
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p> <p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs"> <RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
baselineCompleted: boolean;
lastRunStatus: string | null;
}>();
</script>
<template>
<span
v-if="!baselineCompleted"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
>Pending</span>
<span
v-if="lastRunStatus === 'failed'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-danger-border bg-state-danger-bg px-1.5 py-0.5 text-[10px] font-medium text-state-danger-text"
title="The last scrape run for this scholar failed."
>Failed</span>
<span
v-else-if="lastRunStatus === 'partial'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="The last scrape run for this scholar completed with partial results."
>Partial</span>
<span
v-else-if="lastRunStatus === 'success'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-success-border bg-state-success-bg px-1.5 py-0.5 text-[10px] font-medium text-state-success-text"
title="The last scrape run for this scholar completed successfully."
>OK</span>
</template>

View file

@ -0,0 +1,76 @@
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
export interface ParsedToken {
index: number;
raw: string;
id: string | null;
error: string | null;
}
export function extractScholarIdFromUrl(token: string): string | null {
const cleaned = token.replace(/\/+$/, "").replace(/#.*$/, "");
try {
const parsed = new URL(cleaned);
const userParam = parsed.searchParams.get("user");
if (!userParam) return null;
const decoded = decodeURIComponent(userParam).trim();
if (SCHOLAR_ID_PATTERN.test(decoded)) return decoded;
return null;
} catch {
return null;
}
}
export function validateTokenAsId(token: string): string | null {
const trimmed = token.trim();
if (!trimmed) return "empty input";
if (/\s/.test(trimmed)) return "contains whitespace";
if (trimmed.length !== 12) return `must be 12 characters (got ${trimmed.length})`;
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return "contains invalid characters (only a-z, A-Z, 0-9, _ and - allowed)";
}
return null;
}
export function parseScholarTokens(raw: string): ParsedToken[] {
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
const results: ParsedToken[] = [];
const seen = new Set<string>();
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (SCHOLAR_ID_PATTERN.test(token)) {
if (seen.has(token)) {
results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
continue;
}
seen.add(token);
results.push({ index: i + 1, raw: token, id: token, error: null });
continue;
}
if (token.includes("scholar.google") || token.startsWith("http")) {
const extracted = extractScholarIdFromUrl(token);
if (extracted) {
if (seen.has(extracted)) {
results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
continue;
}
seen.add(extracted);
results.push({ index: i + 1, raw: token, id: extracted, error: null });
continue;
}
results.push({ index: i + 1, raw: token, id: null, error: "could not extract scholar ID from URL" });
continue;
}
const reason = validateTokenAsId(token);
results.push({ index: i + 1, raw: token, id: null, error: reason ?? "invalid scholar ID" });
}
return results;
}
export function parseScholarIds(raw: string): string[] {
return parseScholarTokens(raw).filter((t) => t.id !== null).map((t) => t.id!);
}

View file

@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { nextTick, ref } from "vue";
import { useScholarBulkActions, type ScholarBulkAction } from "./useScholarBulkActions";
import type { ScholarProfile } from "@/features/scholars";
vi.mock("@/features/scholars", () => ({
bulkDeleteScholars: vi.fn(),
bulkToggleScholars: vi.fn(),
exportScholarData: vi.fn(),
}));
function makeProfile(id: number, name: string): ScholarProfile {
return {
id,
scholar_id: `scholar_${id}`,
display_name: name,
profile_image_url: null,
profile_image_source: "none",
is_enabled: true,
baseline_completed: false,
last_run_dt: null,
last_run_status: null,
};
}
function setup(profiles: ScholarProfile[] = []) {
const visibleScholars = ref(profiles);
const callbacks = {
clearMessages: vi.fn(),
assignError: vi.fn(),
setSuccess: vi.fn(),
reloadScholars: vi.fn(async () => {}),
};
const bulk = useScholarBulkActions(visibleScholars, callbacks);
return { visibleScholars, callbacks, bulk };
}
describe("useScholarBulkActions", () => {
it("starts with empty selection", () => {
const { bulk } = setup([makeProfile(1, "Alice")]);
expect(bulk.selectedIds.value.size).toBe(0);
expect(bulk.hasSelection.value).toBe(false);
});
it("toggles individual row selection", () => {
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
expect(bulk.selectedIds.value.has(1)).toBe(true);
expect(bulk.selectedCount.value).toBe(1);
bulk.onToggleRow(1, { target: { checked: false } } as unknown as Event);
expect(bulk.selectedIds.value.has(1)).toBe(false);
expect(bulk.selectedCount.value).toBe(0);
});
it("toggles all visible scholars", () => {
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
expect(bulk.selectedIds.value.size).toBe(2);
expect(bulk.allVisibleSelected.value).toBe(true);
bulk.onToggleAll({ target: { checked: false } } as unknown as Event);
expect(bulk.selectedIds.value.size).toBe(0);
});
it("prunes stale selections when list changes", async () => {
const { visibleScholars, bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
expect(bulk.selectedIds.value.size).toBe(2);
// Remove Bob from the visible list
visibleScholars.value = [makeProfile(1, "Alice")];
await nextTick();
expect(bulk.selectedIds.value.size).toBe(1);
expect(bulk.selectedIds.value.has(1)).toBe(true);
expect(bulk.selectedIds.value.has(2)).toBe(false);
});
it("shows correct bulk action options with and without selection", () => {
const { bulk } = setup([makeProfile(1, "Alice")]);
// Without selection
expect(bulk.bulkActionOptions.value.length).toBe(1);
expect(bulk.bulkActionOptions.value[0].value).toBe("select_all");
// With selection
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
expect(bulk.bulkActionOptions.value.length).toBe(5);
const values = bulk.bulkActionOptions.value.map((o) => o.value);
expect(values).toContain("delete_selected");
expect(values).toContain("enable_selected");
expect(values).toContain("disable_selected");
expect(values).toContain("export_selected");
expect(values).toContain("clear_selection");
});
it("select all action selects all visible", async () => {
const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
bulk.bulkAction.value = "select_all" as ScholarBulkAction;
await bulk.onApplyBulkAction();
expect(bulk.selectedIds.value.size).toBe(2);
});
it("clear selection action clears all", async () => {
const { bulk } = setup([makeProfile(1, "Alice")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
bulk.bulkAction.value = "clear_selection" as ScholarBulkAction;
await bulk.onApplyBulkAction();
expect(bulk.selectedIds.value.size).toBe(0);
});
it("bulk delete calls assignError on network failure", async () => {
const { bulkDeleteScholars } = await import("@/features/scholars");
vi.mocked(bulkDeleteScholars).mockRejectedValueOnce(new Error("Network error"));
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
bulk.bulkAction.value = "delete_selected" as ScholarBulkAction;
await bulk.onApplyBulkAction();
// Trigger the confirm callback
expect(bulk.confirmState.value.open).toBe(true);
await bulk.confirmState.value.onConfirm();
expect(callbacks.assignError).toHaveBeenCalledWith(
expect.any(Error),
"Unable to bulk delete scholars.",
);
expect(bulk.bulkBusy.value).toBe(false);
});
it("bulk toggle calls assignError on network failure", async () => {
const { bulkToggleScholars } = await import("@/features/scholars");
vi.mocked(bulkToggleScholars).mockRejectedValueOnce(new Error("Network error"));
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
bulk.bulkAction.value = "enable_selected" as ScholarBulkAction;
await bulk.onApplyBulkAction();
expect(callbacks.assignError).toHaveBeenCalledWith(
expect.any(Error),
"Unable to bulk enable scholars.",
);
expect(bulk.bulkBusy.value).toBe(false);
});
});

View file

@ -0,0 +1,221 @@
import { computed, ref, watch, type Ref } from "vue";
import {
bulkDeleteScholars,
bulkToggleScholars,
exportScholarData,
type ScholarProfile,
} from "@/features/scholars";
export type ScholarBulkAction =
| "delete_selected"
| "enable_selected"
| "disable_selected"
| "export_selected"
| "clear_selection"
| "select_all";
export interface ScholarBulkActionOption {
value: ScholarBulkAction;
label: string;
}
export interface ConfirmState {
open: boolean;
title: string;
message: string;
variant: "danger" | "default";
onConfirm: () => void;
}
export interface BulkActionCallbacks {
clearMessages: () => void;
assignError: (error: unknown, fallback: string) => void;
setSuccess: (msg: string) => void;
reloadScholars: () => Promise<void>;
}
export function useScholarBulkActions(
visibleScholars: Ref<ScholarProfile[]>,
callbacks: BulkActionCallbacks,
) {
const selectedIds = ref<Set<number>>(new Set());
const bulkAction = ref<ScholarBulkAction>("select_all");
const bulkBusy = ref(false);
const confirmState = ref<ConfirmState>({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
const selectedCount = computed(() => selectedIds.value.size);
const hasSelection = computed(() => selectedCount.value > 0);
const allVisibleSelected = computed(() => {
if (visibleScholars.value.length === 0) return false;
for (const item of visibleScholars.value) {
if (!selectedIds.value.has(item.id)) return false;
}
return true;
});
const bulkActionOptions = computed<ScholarBulkActionOption[]>(() => {
if (!hasSelection.value) return [{ value: "select_all", label: "Select all" }];
const n = selectedCount.value;
return [
{ value: "delete_selected", label: `Delete selected (${n})` },
{ value: "enable_selected", label: `Enable selected (${n})` },
{ value: "disable_selected", label: `Disable selected (${n})` },
{ value: "export_selected", label: `Export selected (${n})` },
{ value: "clear_selection", label: "Clear selection" },
];
});
const bulkApplyLabel = computed(() => {
if (bulkBusy.value) return "Applying...";
if (bulkAction.value === "select_all") return "Select";
if (bulkAction.value === "clear_selection") return "Clear";
return "Apply";
});
const bulkApplyDisabled = computed(() => {
if (bulkBusy.value) return true;
if (bulkAction.value === "select_all") return visibleScholars.value.length === 0;
return selectedCount.value === 0;
});
// Prune stale selections when visible list changes
watch(visibleScholars, (items) => {
const validIds = new Set(items.map((item) => item.id));
const next = new Set<number>();
for (const id of selectedIds.value) {
if (validIds.has(id)) next.add(id);
}
if (next.size !== selectedIds.value.size) selectedIds.value = next;
});
// Reset bulk action dropdown when selection state changes
watch(hasSelection, (has) => {
const validValues = new Set(bulkActionOptions.value.map((o) => o.value));
if (validValues.has(bulkAction.value)) return;
bulkAction.value = has ? "delete_selected" : "select_all";
});
function onToggleAll(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
const next = new Set(selectedIds.value);
for (const item of visibleScholars.value) {
if (checked) { next.add(item.id); } else { next.delete(item.id); }
}
selectedIds.value = next;
}
function onToggleRow(id: number, event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
const next = new Set(selectedIds.value);
if (checked) { next.add(id); } else { next.delete(id); }
selectedIds.value = next;
}
function downloadJsonFile(filename: string, payload: unknown): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
async function onApplyBulkAction(): Promise<void> {
if (bulkApplyDisabled.value) return;
if (bulkAction.value === "select_all") {
selectedIds.value = new Set(visibleScholars.value.map((item) => item.id));
return;
}
if (bulkAction.value === "clear_selection") { selectedIds.value = new Set(); return; }
if (bulkAction.value === "delete_selected") { await onBulkDelete(); return; }
if (bulkAction.value === "enable_selected") { await onBulkToggle(true); return; }
if (bulkAction.value === "disable_selected") { await onBulkToggle(false); return; }
if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
}
function dismissConfirm(): void {
confirmState.value = { ...confirmState.value, open: false };
}
function requestConfirm(title: string, message: string, variant: "danger" | "default", onConfirm: () => void): void {
confirmState.value = { open: true, title, message, variant, onConfirm };
}
function onBulkDelete(): void {
const ids = [...selectedIds.value];
requestConfirm(
`Delete ${ids.length} scholar(s)?`,
"This removes all linked publications and queue data. This action cannot be undone.",
"danger",
async () => {
dismissConfirm();
bulkBusy.value = true;
callbacks.clearMessages();
try {
const result = await bulkDeleteScholars(ids);
callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`);
selectedIds.value = new Set();
await callbacks.reloadScholars();
} catch (error) {
callbacks.assignError(error, "Unable to bulk delete scholars.");
} finally {
bulkBusy.value = false;
}
},
);
}
async function onBulkToggle(isEnabled: boolean): Promise<void> {
bulkBusy.value = true;
callbacks.clearMessages();
try {
const ids = [...selectedIds.value];
const result = await bulkToggleScholars(ids, isEnabled);
const verb = isEnabled ? "enabled" : "disabled";
callbacks.setSuccess(`${result.updated_count} scholar(s) ${verb}.`);
selectedIds.value = new Set();
await callbacks.reloadScholars();
} catch (error) {
callbacks.assignError(error, `Unable to bulk ${isEnabled ? "enable" : "disable"} scholars.`);
} finally {
bulkBusy.value = false;
}
}
async function onBulkExport(): Promise<void> {
bulkBusy.value = true;
callbacks.clearMessages();
try {
const ids = [...selectedIds.value];
const payload = await exportScholarData(ids);
const dateSlug = payload.exported_at.slice(0, 10) || "unknown-date";
downloadJsonFile(`scholarr-export-${dateSlug}.json`, payload);
callbacks.setSuccess("Export complete.");
} catch (error) {
callbacks.assignError(error, "Unable to export selected scholars.");
} finally {
bulkBusy.value = false;
}
}
return {
selectedIds,
selectedCount,
hasSelection,
allVisibleSelected,
bulkAction,
bulkBusy,
bulkActionOptions,
bulkApplyLabel,
bulkApplyDisabled,
confirmState,
dismissConfirm,
requestConfirm,
onToggleAll,
onToggleRow,
onApplyBulkAction,
};
}

View file

@ -160,8 +160,30 @@ export async function clearScholarImage(
return response.data; return response.data;
} }
export async function exportScholarData(): Promise<DataExportPayload> { export interface BulkCountResult {
const response = await apiRequest<DataExportPayload>("/scholars/export", { deleted_count: number;
updated_count: number;
}
export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise<BulkCountResult> {
const response = await apiRequest<BulkCountResult>("/scholars/bulk-delete", {
method: "POST",
body: { scholar_profile_ids: scholarProfileIds },
});
return response.data;
}
export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise<BulkCountResult> {
const response = await apiRequest<BulkCountResult>("/scholars/bulk-toggle", {
method: "POST",
body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled },
});
return response.data;
}
export async function exportScholarData(ids?: number[]): Promise<DataExportPayload> {
const path = ids && ids.length > 0 ? `/scholars/export?ids=${ids.join(",")}` : "/scholars/export";
const response = await apiRequest<DataExportPayload>(path, {
method: "GET", method: "GET",
}); });
return response.data; return response.data;

View file

@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import SettingsAdminPanel from "./SettingsAdminPanel.vue";
vi.mock("@/features/admin_users", () => ({
listAdminUsers: vi.fn().mockResolvedValue([]),
createAdminUser: vi.fn(),
setAdminUserActive: vi.fn(),
resetAdminUserPassword: vi.fn(),
}));
vi.mock("@/features/admin_dbops", () => ({
getAdminDbIntegrityReport: vi.fn().mockResolvedValue({
status: "ok",
warnings: [],
failures: [],
checked_at: null,
checks: [],
}),
listAdminPdfQueue: vi.fn().mockResolvedValue({
items: [],
total_count: 0,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
}),
requeueAdminPdfLookup: vi.fn(),
requeueAllAdminPdfLookups: vi.fn(),
}));
vi.mock("@/stores/auth", () => ({
useAuthStore: () => ({ isAdmin: true }),
}));
vi.mock("@/features/admin_repairs", () => ({
listAdminRepairTasks: vi.fn().mockResolvedValue([]),
runAdminRepairTask: vi.fn(),
}));
import { listAdminUsers } from "@/features/admin_users";
import { getAdminDbIntegrityReport, listAdminPdfQueue } from "@/features/admin_dbops";
const mockedListUsers = vi.mocked(listAdminUsers);
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
const mockedListPdfQueue = vi.mocked(listAdminPdfQueue);
describe("SettingsAdminPanel", () => {
beforeEach(() => {
mockedListUsers.mockClear();
mockedGetReport.mockClear();
mockedListPdfQueue.mockClear();
});
it("calls load on users section after nextTick when section=users", async () => {
mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListUsers).toHaveBeenCalled();
});
it("calls load on integrity section after nextTick when section=integrity", async () => {
mount(SettingsAdminPanel, { props: { section: "integrity" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on new section when section prop changes", async () => {
const wrapper = mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
mockedListUsers.mockClear();
mockedGetReport.mockClear();
await wrapper.setProps({ section: "integrity" });
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on pdf section when section=pdf", async () => {
mount(SettingsAdminPanel, { props: { section: "pdf" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListPdfQueue).toHaveBeenCalled();
});
});

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from "vue"; import { nextTick, onMounted, watch } from "vue";
import { ref } from "vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue"; import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue";
import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue"; import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue";
@ -18,7 +18,6 @@ const props = defineProps<{
section: "users" | "integrity" | "repairs" | "pdf"; section: "users" | "integrity" | "repairs" | "pdf";
}>(); }>();
const loading = ref(true);
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState(); const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState();
const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null); const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null);
@ -26,39 +25,33 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null); const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null); const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
async function refreshForSection(): Promise<void> { let loadGeneration = 0;
if (props.section === SECTION_USERS && usersRef.value) {
await usersRef.value.load();
return;
}
if (props.section === SECTION_INTEGRITY && integrityRef.value) {
await integrityRef.value.load();
return;
}
if (props.section === SECTION_REPAIRS) {
await usersRef.value?.load();
await repairsRef.value?.load();
return;
}
if (props.section === SECTION_PDF && pdfQueueRef.value) {
await pdfQueueRef.value.load();
}
}
async function loadSection(): Promise<void> { async function loadSection(): Promise<void> {
loading.value = true; const gen = ++loadGeneration;
clearAlerts(); clearAlerts();
try { try {
await refreshForSection(); if (props.section === SECTION_USERS && usersRef.value) {
await usersRef.value.load();
} else if (props.section === SECTION_INTEGRITY && integrityRef.value) {
await integrityRef.value.load();
} else if (props.section === SECTION_REPAIRS) {
await usersRef.value?.load();
if (gen !== loadGeneration) return;
await repairsRef.value?.load();
} else if (props.section === SECTION_PDF && pdfQueueRef.value) {
await pdfQueueRef.value.load();
}
} catch (error) { } catch (error) {
if (gen !== loadGeneration) return;
assignError(error, "Unable to load admin data."); assignError(error, "Unable to load admin data.");
} finally {
loading.value = false;
} }
} }
onMounted(loadSection); onMounted(() => {
watch(() => props.section, loadSection); nextTick(loadSection);
});
watch(() => props.section, loadSection, { flush: "post" });
</script> </script>
<template> <template>
@ -72,11 +65,9 @@ watch(() => props.section, loadSection);
@dismiss-success="successMessage = null" @dismiss-success="successMessage = null"
/> />
<AsyncStateGate :loading="loading" :loading-lines="10" :show-empty="false"> <AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" />
<AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" /> <AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" />
<AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" /> <AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" />
<AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" /> <AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
<AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
</AsyncStateGate>
</section> </section>
</template> </template>

View file

@ -1,6 +1,8 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils"; import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { useAuthStore } from "@/stores/auth";
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue"; import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
vi.mock("@/features/admin_dbops", () => ({ vi.mock("@/features/admin_dbops", () => ({
@ -36,6 +38,9 @@ function buildQueueItem(overrides: Record<string, unknown> = {}) {
describe("AdminPdfQueueSection", () => { describe("AdminPdfQueueSection", () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.$patch({ state: "authenticated", user: { id: 1, email: "admin@test.com", is_admin: true, is_active: true } });
mockedListQueue.mockReset(); mockedListQueue.mockReset();
}); });

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import AppBadge from "@/components/ui/AppBadge.vue"; import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
@ -17,6 +18,7 @@ import {
import { useRequestState } from "@/composables/useRequestState"; import { useRequestState } from "@/composables/useRequestState";
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState(); const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
const auth = useAuthStore();
const refreshingPdfQueue = ref(false); const refreshingPdfQueue = ref(false);
const requeueingPublicationId = ref<number | null>(null); const requeueingPublicationId = ref<number | null>(null);
@ -162,7 +164,7 @@ defineExpose({ load });
<option value="50">50 / page</option> <option value="50">50 / page</option>
<option value="100">100 / page</option> <option value="100">100 / page</option>
</AppSelect> </AppSelect>
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs"> <AppButton v-if="auth.isAdmin" variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }} {{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
</AppButton> </AppButton>
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" /> <AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
@ -194,7 +196,7 @@ defineExpose({ load });
<td>{{ formatTimestamp(item.last_attempt_at) }}</td> <td>{{ formatTimestamp(item.last_attempt_at) }}</td>
<td>{{ formatTimestamp(item.resolved_at) }}</td> <td>{{ formatTimestamp(item.resolved_at) }}</td>
<td> <td>
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)"> <AppButton v-if="auth.isAdmin" variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }} {{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
</AppButton> </AppButton>
</td> </td>

View file

@ -404,7 +404,7 @@ watch(
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AppButton <AppButton
v-if="auth.isAdmin && runStatus.isLikelyRunning" v-if="runStatus.isLikelyRunning"
variant="danger" variant="danger"
:disabled="!activeRunId || isCancelAnimating" :disabled="!activeRunId || isCancelAnimating"
@click="onCancelRun" @click="onCancelRun"
@ -420,7 +420,6 @@ watch(
</span> </span>
</AppButton> </AppButton>
<AppButton <AppButton
v-if="auth.isAdmin"
:disabled="isStartBlocked" :disabled="isStartBlocked"
:title="startCheckDisabledReason || undefined" :title="startCheckDisabledReason || undefined"
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''" :class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
@ -486,9 +485,21 @@ watch(
</RouterLink> </RouterLink>
</div> </div>
<p class="mt-2 text-sm text-secondary"> <p class="mt-2 text-sm text-secondary">
Started {{ formatDate(displayedLatestRun.start_dt) }}. Processed Started {{ formatDate(displayedLatestRun.start_dt) }}.
{{ displayedLatestRun.scholar_count }} scholars and discovered <template v-if="runStatus.isLikelyRunning">
{{ displayedLatestRun.new_publication_count }} new publications. {{ runStatus.scholarProgress?.visited ?? 0 }} / {{ runStatus.scholarProgress?.total ?? '…' }} visited
· {{ runStatus.scholarProgress?.finished ?? 0 }} finished
· {{ displayedLatestRun.new_publication_count }} new publications.
</template>
<template v-else>
Processed {{ displayedLatestRun.scholar_count }} scholars and discovered
{{ displayedLatestRun.new_publication_count }} new publications.
<template v-if="displayedLatestRun.failed_count > 0 || displayedLatestRun.partial_count > 0">
<span class="text-state-warning-text">
{{ displayedLatestRun.failed_count > 0 ? `${displayedLatestRun.failed_count} failed` : "" }}{{ displayedLatestRun.failed_count > 0 && displayedLatestRun.partial_count > 0 ? ", " : "" }}{{ displayedLatestRun.partial_count > 0 ? `${displayedLatestRun.partial_count} partial` : "" }}.
</span>
</template>
</template>
</p> </p>
</div> </div>
<AppEmptyState <AppEmptyState
@ -521,6 +532,9 @@ watch(
</div> </div>
<span class="text-xs text-secondary"> <span class="text-xs text-secondary">
{{ formatDate(run.start_dt) }} {{ run.new_publication_count }} new {{ formatDate(run.start_dt) }} {{ run.new_publication_count }} new
<template v-if="run.failed_count > 0">
<span class="text-state-warning-text">{{ run.failed_count }} failed</span>
</template>
</span> </span>
</li> </li>
</ul> </ul>

View file

@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppConfirmModal from "@/components/ui/AppConfirmModal.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
@ -27,10 +28,12 @@ import {
type ScholarProfile, type ScholarProfile,
type ScholarSearchCandidate, type ScholarSearchCandidate,
} from "@/features/scholars"; } from "@/features/scholars";
import { useScholarBulkActions } from "@/features/scholars/composables/useScholarBulkActions";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue"; import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue";
import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue";
import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue"; import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue";
import ScholarStatusBadges from "@/features/scholars/components/ScholarStatusBadges.vue";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status"; import { useRunStatusStore } from "@/stores/run_status";
@ -270,102 +273,71 @@ async function onToggleScholar(): Promise<void> {
} }
} }
async function onDeleteScholar(): Promise<void> { function onDeleteScholar(): void {
const profile = activeScholarSettings.value; const profile = activeScholarSettings.value;
if (!profile) return; if (!profile) return;
const label = scholarLabel(profile); const label = scholarLabel(profile);
if (!window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`)) return; bulk.requestConfirm(
activeScholarId.value = profile.id; `Delete ${label}?`,
clearMessages(); "This removes all linked publications and queue data. This action cannot be undone.",
try { "danger",
await deleteScholar(profile.id); async () => {
successMessage.value = `${label} deleted.`; bulk.dismissConfirm();
activeScholarSettingsId.value = null; activeScholarId.value = profile.id;
await loadScholars(); clearMessages();
} catch (error) { try {
assignError(error, "Unable to delete scholar."); await deleteScholar(profile.id);
} finally { successMessage.value = `${label} deleted.`;
activeScholarId.value = null; activeScholarSettingsId.value = null;
} await loadScholars();
} catch (error) {
assignError(error, "Unable to delete scholar.");
} finally {
activeScholarId.value = null;
}
},
);
} }
async function onSaveImageUrl(): Promise<void> { async function onSaveImageUrl(): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim(); const url = (imageUrlDraftByScholarId.value[p.id] || "").trim();
if (!candidate) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } if (!url) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; }
imageSavingScholarId.value = profile.id; imageSavingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await setScholarImageUrl(p.id, url); successMessage.value = `Image URL updated for ${scholarLabel(p)}.`; await loadScholars(); }
await setScholarImageUrl(profile.id, candidate); catch (e) { assignError(e, "Unable to update scholar image URL."); }
successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`; finally { imageSavingScholarId.value = null; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to update scholar image URL.");
} finally {
imageSavingScholarId.value = null;
}
} }
async function onUploadImage(event: Event): Promise<void> { async function onUploadImage(event: Event): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
const input = event.target as HTMLInputElement | null; const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null; const file = input?.files?.[0] ?? null;
if (!file) return; if (!file) return;
imageUploadingScholarId.value = profile.id; imageUploadingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await uploadScholarImage(p.id, file); successMessage.value = `Uploaded image for ${scholarLabel(p)}.`; await loadScholars(); }
await uploadScholarImage(profile.id, file); catch (e) { assignError(e, "Unable to upload scholar image."); }
successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`; finally { imageUploadingScholarId.value = null; if (input) input.value = ""; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to upload scholar image.");
} finally {
imageUploadingScholarId.value = null;
if (input) input.value = "";
}
} }
async function onResetImage(): Promise<void> { async function onResetImage(): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
imageSavingScholarId.value = profile.id; imageSavingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await clearScholarImage(p.id); successMessage.value = `Image reset for ${scholarLabel(p)}.`; await loadScholars(); }
await clearScholarImage(profile.id); catch (e) { assignError(e, "Unable to reset scholar image."); }
successMessage.value = `Image reset for ${scholarLabel(profile)}.`; finally { imageSavingScholarId.value = null; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to reset scholar image.");
} finally {
imageSavingScholarId.value = null;
}
} }
// --- Import/export --- // --- Import/export ---
function suggestExportFilename(exportedAt: string): string { function importSummary(r: DataImportResult): string {
return `scholarr-export-${exportedAt.slice(0, 10) || "unknown-date"}.json`; return `Import complete. Scholars +${r.scholars_created} / updated ${r.scholars_updated}; publications +${r.publications_created} / updated ${r.publications_updated}; links +${r.links_created} / updated ${r.links_updated}; skipped ${r.skipped_records}.`;
}
function downloadJsonFile(filename: string, payload: unknown): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function importSummary(result: DataImportResult): string {
return (
`Import complete. Scholars +${result.scholars_created}` +
` / updated ${result.scholars_updated}; publications +${result.publications_created}` +
` / updated ${result.publications_updated}; links +${result.links_created}` +
` / updated ${result.links_updated}; skipped ${result.skipped_records}.`
);
} }
async function onExportData(): Promise<void> { async function onExportData(): Promise<void> {
@ -373,7 +345,13 @@ async function onExportData(): Promise<void> {
clearMessages(); clearMessages();
try { try {
const payload = await exportScholarData(); const payload = await exportScholarData();
downloadJsonFile(suggestExportFilename(payload.exported_at), payload); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `scholarr-export-${payload.exported_at.slice(0, 10) || "unknown-date"}.json`;
a.click();
URL.revokeObjectURL(url);
successMessage.value = "Export complete."; successMessage.value = "Export complete.";
} catch (error) { } catch (error) {
assignError(error, "Unable to export scholars and publications."); assignError(error, "Unable to export scholars and publications.");
@ -382,10 +360,6 @@ async function onExportData(): Promise<void> {
} }
} }
function onOpenImportPicker(): void {
importFileInput.value?.click();
}
async function onImportFileSelected(event: Event): Promise<void> { async function onImportFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement | null; const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null; const file = input?.files?.[0] ?? null;
@ -395,16 +369,12 @@ async function onImportFileSelected(event: Event): Promise<void> {
try { try {
const raw = await file.text(); const raw = await file.text();
let parsed = JSON.parse(raw); let parsed = JSON.parse(raw);
// Accept the full export envelope (with data/meta wrapper) if (parsed?.data && Array.isArray(parsed.data.scholars)) parsed = parsed.data;
if (parsed?.data && Array.isArray(parsed.data.scholars)) {
parsed = parsed.data;
}
const payload = parsed as DataImportPayload; const payload = parsed as DataImportPayload;
if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) { if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) {
throw new Error("Invalid import file: expected scholars[] and publications[] arrays."); throw new Error("Invalid import file: expected scholars[] and publications[] arrays.");
} }
const result = await importScholarData(payload); successMessage.value = importSummary(await importScholarData(payload));
successMessage.value = importSummary(result);
await loadScholars(); await loadScholars();
} catch (error) { } catch (error) {
assignError(error, "Unable to import scholars and publications."); assignError(error, "Unable to import scholars and publications.");
@ -414,6 +384,15 @@ async function onImportFileSelected(event: Event): Promise<void> {
} }
} }
// --- Bulk actions ---
const bulk = useScholarBulkActions(visibleScholars, {
clearMessages,
assignError,
setSuccess: (msg: string) => { successMessage.value = msg; },
reloadScholars: loadScholars,
});
// --- Lifecycle --- // --- Lifecycle ---
onMounted(() => { void loadScholars(); }); onMounted(() => { void loadScholars(); });
@ -471,7 +450,7 @@ watch(
<AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData"> <AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData">
{{ exportingData ? "Exporting..." : "Export" }} {{ exportingData ? "Exporting..." : "Export" }}
</AppButton> </AppButton>
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker"> <AppButton variant="secondary" :disabled="loading || importingData" @click="importFileInput?.click()">
{{ importingData ? "Importing..." : "Import" }} {{ importingData ? "Importing..." : "Import" }}
</AppButton> </AppButton>
<AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" /> <AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" />
@ -496,6 +475,34 @@ watch(
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-2">
<span class="text-xs text-secondary">
{{ trackedCountLabel }}
<template v-if="bulk.hasSelection.value"> · {{ bulk.selectedCount.value }} selected</template>
</span>
<div class="flex items-center gap-1">
<label for="scholars-bulk-action" class="sr-only">Bulk action</label>
<AppSelect
id="scholars-bulk-action"
v-model="bulk.bulkAction.value"
:disabled="bulk.bulkBusy.value || loading"
class="max-w-[14rem] !py-1.5 !text-xs"
>
<option v-for="option in bulk.bulkActionOptions.value" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</AppSelect>
<AppButton
variant="secondary"
class="h-8 min-h-8 shrink-0 px-2 text-xs"
:disabled="bulk.bulkApplyDisabled.value"
@click="bulk.onApplyBulkAction"
>
{{ bulk.bulkApplyLabel.value }}
</AppButton>
</div>
</div>
<div class="min-h-0 flex-1 xl:overflow-hidden"> <div class="min-h-0 flex-1 xl:overflow-hidden">
<AsyncStateGate :loading="loading" :loading-lines="6" :empty="!hasTrackedScholars" :show-empty="!errorMessage" empty-title="No scholars tracked" empty-body="Add a Scholar ID or URL to start ingestion tracking."> <AsyncStateGate :loading="loading" :loading-lines="6" :empty="!hasTrackedScholars" :show-empty="!errorMessage" empty-title="No scholars tracked" empty-body="Add a Scholar ID or URL to start ingestion tracking.">
<AppEmptyState v-if="!hasVisibleScholars" title="No scholars match this filter" body="Clear or adjust the filter to see tracked scholars." /> <AppEmptyState v-if="!hasVisibleScholars" title="No scholars match this filter" body="Clear or adjust the filter to see tracked scholars." />
@ -503,9 +510,19 @@ watch(
<ul class="flex gap-3 overflow-x-auto p-1 lg:hidden"> <ul class="flex gap-3 overflow-x-auto p-1 lg:hidden">
<li v-for="item in visibleScholars" :key="item.id" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"> <li v-for="item in visibleScholars" :key="item.id" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<input
type="checkbox"
class="bulk-check mt-1 shrink-0"
:checked="bulk.selectedIds.value.has(item.id)"
:aria-label="`Select ${scholarLabel(item)}`"
@change="bulk.onToggleRow(item.id, $event)"
/>
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p> <p class="truncate text-sm font-semibold text-ink-primary">
{{ scholarLabel(item) }}
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
</p>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a> <a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
@ -519,17 +536,39 @@ watch(
<AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table"> <AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table">
<thead> <thead>
<tr> <tr>
<th scope="col" class="w-10">
<input
type="checkbox"
class="bulk-check"
:checked="bulk.allVisibleSelected.value"
:disabled="visibleScholars.length === 0"
aria-label="Select all visible scholars"
@change="bulk.onToggleAll"
/>
</th>
<th scope="col">Scholar</th> <th scope="col">Scholar</th>
<th scope="col" class="w-[11rem]">Manage</th> <th scope="col" class="w-[11rem]">Manage</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in visibleScholars" :key="item.id"> <tr v-for="item in visibleScholars" :key="item.id">
<td>
<input
type="checkbox"
class="bulk-check"
:checked="bulk.selectedIds.value.has(item.id)"
:aria-label="`Select ${scholarLabel(item)}`"
@change="bulk.onToggleRow(item.id, $event)"
/>
</td>
<td> <td>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
<div class="grid min-w-0 gap-1"> <div class="grid min-w-0 gap-1">
<strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong> <strong class="truncate text-ink-primary">
{{ scholarLabel(item) }}
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
</strong>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a> <a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
@ -566,5 +605,21 @@ watch(
@toggle="onToggleScholar" @toggle="onToggleScholar"
@delete="onDeleteScholar" @delete="onDeleteScholar"
/> />
<AppConfirmModal
:open="bulk.confirmState.value.open"
:title="bulk.confirmState.value.title"
:message="bulk.confirmState.value.message"
:variant="bulk.confirmState.value.variant"
confirm-label="Delete"
@confirm="bulk.confirmState.value.onConfirm()"
@cancel="bulk.dismissConfirm()"
/>
</AppPage> </AppPage>
</template> </template>
<style scoped>
.bulk-check {
@apply h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
}
</style>

View file

@ -47,7 +47,7 @@ const savingScholarHttp = ref(false);
const updatingPassword = ref(false); const updatingPassword = ref(false);
const autoRunEnabled = ref(false); const autoRunEnabled = ref(false);
const runIntervalMinutes = ref("60"); const runIntervalHours = ref("1");
const requestDelaySeconds = ref("2"); const requestDelaySeconds = ref("2");
const navVisiblePages = ref<string[]>([]); const navVisiblePages = ref<string[]>([]);
const openalexApiKey = ref(""); const openalexApiKey = ref("");
@ -77,13 +77,13 @@ const tabItems = computed<AppTabItem[]>(() => {
const tabs: AppTabItem[] = [ const tabs: AppTabItem[] = [
{ id: TAB_CHECKING, label: "Checking" }, { id: TAB_CHECKING, label: "Checking" },
{ id: TAB_ACCOUNT, label: "Account" }, { id: TAB_ACCOUNT, label: "Account" },
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
]; ];
if (auth.isAdmin) { if (auth.isAdmin) {
tabs.push( tabs.push(
{ id: TAB_ADMIN_USERS, label: "Users" }, { id: TAB_ADMIN_USERS, label: "Users" },
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" }, { id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" }, { id: TAB_ADMIN_REPAIRS, label: "Repairs" },
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" }, { id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
); );
} }
@ -138,7 +138,7 @@ function hydrateSettings(settings: UserSettings): void {
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true); manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value; autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
runIntervalMinutes.value = String(settings.run_interval_minutes); runIntervalHours.value = String(Number(settings.run_interval_minutes) / 60);
requestDelaySeconds.value = String(settings.request_delay_seconds); requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages); navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
@ -168,6 +168,23 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
return parsed; return parsed;
} }
function formatHours(minutes: number): string {
const h = minutes / 60;
return Number.isInteger(h) ? String(h) : h.toFixed(2).replace(/\.?0+$/, "");
}
function parseHoursToMinutes(value: string, minMinutes: number): number {
const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) {
throw new Error("Check interval must be a positive number of hours.");
}
const minutes = Math.round(hours * 60);
if (minutes < minMinutes) {
throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`);
}
return minutes;
}
async function loadSettings(): Promise<void> { async function loadSettings(): Promise<void> {
loading.value = true; loading.value = true;
errorMessage.value = null; errorMessage.value = null;
@ -225,9 +242,8 @@ async function onSaveSettings(): Promise<void> {
try { try {
const payload: UserSettingsUpdate = { const payload: UserSettingsUpdate = {
auto_run_enabled: autoRunEnabled.value, auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parseBoundedInteger( run_interval_minutes: parseHoursToMinutes(
runIntervalMinutes.value, runIntervalHours.value,
"Check interval (minutes)",
minCheckIntervalMinutes.value, minCheckIntervalMinutes.value,
), ),
request_delay_seconds: parseBoundedInteger( request_delay_seconds: parseBoundedInteger(
@ -357,11 +373,11 @@ onMounted(async () => {
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
Check interval (minutes) Check interval (hours)
<AppHelpHint text="Minimum is controlled by server policy." /> <AppHelpHint text="Minimum is controlled by server policy." />
</span> </span>
<AppInput v-model="runIntervalMinutes" inputmode="numeric" /> <AppInput v-model="runIntervalHours" inputmode="decimal" />
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }}</span> <span class="text-xs text-secondary">Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours</span>
</label> </label>
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">

View file

@ -168,6 +168,7 @@ export const useRunStatusStore = defineStore("runStatus", {
lastErrorRequestId: null as string | null, lastErrorRequestId: null as string | null,
lastSyncAt: null as number | null, lastSyncAt: null as number | null,
livePublications: [] as Array<PublicationItem>, livePublications: [] as Array<PublicationItem>,
scholarProgress: null as { visited: number; finished: number; total: number } | null,
}), }),
getters: { getters: {
isRunActive(state): boolean { isRunActive(state): boolean {
@ -257,6 +258,7 @@ export const useRunStatusStore = defineStore("runStatus", {
} }
activeStreamRunId = targetRunId; activeStreamRunId = targetRunId;
this.livePublications = []; this.livePublications = [];
this.scholarProgress = null;
eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`); eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`);
eventSource.addEventListener("publication_discovered", (e) => { eventSource.addEventListener("publication_discovered", (e) => {
try { try {
@ -312,6 +314,19 @@ export const useRunStatusStore = defineStore("runStatus", {
console.error("Failed to parse SSE event", err); console.error("Failed to parse SSE event", err);
} }
}); });
eventSource.addEventListener("scholar_progress", (e) => {
try {
const data = JSON.parse(e.data);
const visited = typeof data.visited === "number" ? Math.max(0, Math.trunc(data.visited)) : null;
const finished = typeof data.finished === "number" ? Math.max(0, Math.trunc(data.finished)) : null;
const total = typeof data.total === "number" ? Math.max(1, Math.trunc(data.total)) : null;
if (visited !== null && finished !== null && total !== null) {
this.scholarProgress = { visited, finished, total };
}
} catch (err) {
console.error("Failed to parse SSE event", err);
}
});
eventSource.onerror = () => { eventSource.onerror = () => {
// Reconnecting is handled automatically by EventSource, // Reconnecting is handled automatically by EventSource,
// but if it's permanently closed, we could do something here. // but if it's permanently closed, we could do something here.
@ -491,6 +506,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastSyncAt = null; this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState(); this.safetyState = createDefaultSafetyState();
this.livePublications = []; this.livePublications = [];
this.scholarProgress = null;
}, },
}, },
}); });

View file

@ -340,10 +340,11 @@ async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_se
assert forbidden_integrity.status_code == 403 assert forbidden_integrity.status_code == 403
assert forbidden_integrity.json()["error"]["code"] == "forbidden" assert forbidden_integrity.json()["error"]["code"] == "forbidden"
forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue") # PDF queue listing is accessible to all authenticated users (not admin-only).
assert forbidden_pdf_queue.status_code == 403 allowed_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden" assert allowed_pdf_queue.status_code == 200
# But requeue actions still require admin.
forbidden_requeue = client.post( forbidden_requeue = client.post(
"/api/v1/admin/db/pdf-queue/1/requeue", "/api/v1/admin/db/pdf-queue/1/requeue",
headers=headers, headers=headers,

View file

@ -70,6 +70,110 @@ async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
assert delete_response.json()["data"]["message"] == "Scholar deleted." assert delete_response.json()["data"]["message"] == "Scholar deleted."
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_delete_scholar_returns_404_for_nonexistent(db_session: AsyncSession) -> None:
await insert_user(
db_session,
email="api-delete-404@example.com",
password="api-password",
)
client = TestClient(app)
login_user(client, email="api-delete-404@example.com", password="api-password")
headers = api_csrf_headers(client)
response = client.delete("/api/v1/scholars/999999", headers=headers)
assert response.status_code == 404
assert response.json()["error"]["code"] == "scholar_not_found"
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_delete_scholar_cascades_linked_publications(db_session: AsyncSession) -> None:
user_id = await insert_user(
db_session,
email="api-delete-cascade@example.com",
password="api-password",
)
scholar_result = await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
VALUES (:user_id, :scholar_id, :display_name, true)
RETURNING id
"""
),
{"user_id": user_id, "scholar_id": "delCASCADE01", "display_name": "Cascade Test"},
)
scholar_profile_id = int(scholar_result.scalar_one())
pub_result = await db_session.execute(
text(
"""
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
VALUES (:fingerprint, :title_raw, :title_normalized, 0)
RETURNING id
"""
),
{
"fingerprint": f"{(user_id + 7000):064x}",
"title_raw": "Cascade Publication",
"title_normalized": "cascadepublication",
},
)
publication_id = int(pub_result.scalar_one())
await db_session.execute(
text(
"""
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
VALUES (:scholar_profile_id, :publication_id, false)
"""
),
{"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
)
await db_session.commit()
client = TestClient(app)
login_user(client, email="api-delete-cascade@example.com", password="api-password")
headers = api_csrf_headers(client)
delete_response = client.delete(f"/api/v1/scholars/{scholar_profile_id}", headers=headers)
assert delete_response.status_code == 200
assert delete_response.json()["data"]["message"] == "Scholar deleted."
link_result = await db_session.execute(
text("SELECT count(*) FROM scholar_publications WHERE scholar_profile_id = :id"),
{"id": scholar_profile_id},
)
assert int(link_result.scalar_one()) == 0
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_create_scholar_rejects_corrupted_id(db_session: AsyncSession) -> None:
await insert_user(
db_session,
email="api-bad-id@example.com",
password="api-password",
)
client = TestClient(app)
login_user(client, email="api-bad-id@example.com", password="api-password")
headers = api_csrf_headers(client)
for bad_id in ["short", "ABCDEF12345!", "", " ", "AB CD EF1234"]:
response = client.post(
"/api/v1/scholars",
json={"scholar_id": bad_id},
headers=headers,
)
assert response.status_code == 400, f"Expected 400 for scholar_id={bad_id!r}"
assert response.json()["error"]["code"] == "invalid_scholar"
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.db @pytest.mark.db
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -0,0 +1,170 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from tests.integration.helpers import (
api_csrf_headers,
insert_user,
login_user,
)
def _create_scholar(client: TestClient, headers: dict, scholar_id: str) -> int:
resp = client.post("/api/v1/scholars", json={"scholar_id": scholar_id}, headers=headers)
assert resp.status_code == 201
return int(resp.json()["data"]["id"])
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_with_valid_ids(db_session: AsyncSession) -> None:
await insert_user(db_session, email="bulk@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="bulk@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
id2 = _create_scholar(client, headers, "cccDDD333444")
resp = client.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [id1, id2]},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["deleted_count"] == 2
list_resp = client.get("/api/v1/scholars")
assert len(list_resp.json()["data"]["scholars"]) == 0
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_only_deletes_own_scholars(db_session: AsyncSession) -> None:
await insert_user(db_session, email="user1@example.com", password="pw123456")
await insert_user(db_session, email="user2@example.com", password="pw123456")
client1 = TestClient(app)
login_user(client1, email="user1@example.com", password="pw123456")
headers1 = api_csrf_headers(client1)
client2 = TestClient(app)
login_user(client2, email="user2@example.com", password="pw123456")
headers2 = api_csrf_headers(client2)
id_user1 = _create_scholar(client1, headers1, "aaaBBB111222")
id_user2 = _create_scholar(client2, headers2, "cccDDD333444")
# User1 tries to delete both — should only delete own
resp = client1.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [id_user1, id_user2]},
headers=headers1,
)
assert resp.status_code == 200
assert resp.json()["data"]["deleted_count"] == 1
# User2's scholar still exists
list_resp = client2.get("/api/v1/scholars")
scholars = list_resp.json()["data"]["scholars"]
assert len(scholars) == 1
assert int(scholars[0]["id"]) == id_user2
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_toggle_enables_and_disables(db_session: AsyncSession) -> None:
await insert_user(db_session, email="toggle@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="toggle@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
id2 = _create_scholar(client, headers, "cccDDD333444")
# Disable both
resp = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [id1, id2], "is_enabled": False},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["updated_count"] == 2
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
for s in scholars:
assert s["is_enabled"] is False
# Re-enable both
resp = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [id1, id2], "is_enabled": True},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["updated_count"] == 2
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
for s in scholars:
assert s["is_enabled"] is True
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
await insert_user(db_session, email="export@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="export@example.com", password="pw123456")
headers = api_csrf_headers(client)
id1 = _create_scholar(client, headers, "aaaBBB111222")
_create_scholar(client, headers, "cccDDD333444")
# Export only id1
resp = client.get(f"/api/v1/scholars/export?ids={id1}")
assert resp.status_code == 200
data = resp.json()["data"]
assert len(data["scholars"]) == 1
assert data["scholars"][0]["scholar_id"] == "aaaBBB111222"
# Export all (no filter)
resp_all = client.get("/api/v1/scholars/export")
assert resp_all.status_code == 200
assert len(resp_all.json()["data"]["scholars"]) == 2
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-del@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-del@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [1]},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_toggle_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-tog@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-tog@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [1], "is_enabled": False},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"

View file

@ -42,6 +42,34 @@ class TestValidateScholarId:
with pytest.raises(ScholarServiceError, match="12"): with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF12345!") validate_scholar_id("ABCDEF12345!")
def test_rejects_empty_string(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("")
def test_rejects_whitespace_only(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id(" ")
def test_rejects_embedded_spaces(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF 12345")
def test_rejects_tab_characters(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF\t12345")
def test_rejects_url_as_id(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("https://scholar.google.com/citations?user=ABCDEF123456")
def test_rejects_newline_in_id(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF\n12345")
def test_rejects_unicode_characters(self) -> None:
with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF12345\u00e9")
class TestNormalizeDisplayName: class TestNormalizeDisplayName:
def test_returns_stripped_name(self) -> None: def test_returns_stripped_name(self) -> None: