diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index ce63ea2..048be70 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -57,7 +57,7 @@ def _requested_by_value(*, payload, admin_user: User) -> str: 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 { "publication_id": item.publication_id, "title": item.title, @@ -69,7 +69,7 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]: "last_failure_detail": item.last_failure_detail, "last_source": item.last_source, "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, "last_attempt_at": item.last_attempt_at, "resolved_at": item.resolved_at, @@ -215,7 +215,7 @@ async def get_pdf_queue( return success_payload( request, 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( total_count=queue_page.total_count, page=resolved_page, diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7bbc642..2a0c276 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -23,6 +23,9 @@ from app.api.schemas import ( DataImportEnvelope, DataImportRequest, MessageEnvelope, + ScholarBulkCountEnvelope, + ScholarBulkIdsRequest, + ScholarBulkToggleRequest, ScholarCreateRequest, ScholarEnvelope, ScholarImageUrlUpdateRequest, @@ -42,6 +45,22 @@ logger = logging.getLogger(__name__) 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( "", response_model=ScholarsListEnvelope, @@ -69,16 +88,81 @@ async def list_scholars( ) async def export_scholars_and_publications( request: Request, + ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): + scholar_profile_ids = _parse_ids_param(ids) data = await import_export_service.export_user_data( db_session, user_id=current_user.id, + scholar_profile_ids=scholar_profile_ids, ) 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( "/import", response_model=DataImportEnvelope, @@ -261,11 +345,18 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - await scholar_service.delete_scholar( - db_session, - profile=profile, - upload_dir=settings.scholar_image_upload_dir, - ) + try: + await scholar_service.delete_scholar( + db_session, + 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( logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id ) diff --git a/app/api/schemas/scholars.py b/app/api/schemas/scholars.py index 91cc459..bc1035d 100644 --- a/app/api/schemas/scholars.py +++ b/app/api/schemas/scholars.py @@ -126,6 +126,33 @@ class DataExportEnvelope(BaseModel): 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): schema_version: int | None = None exported_at: str | None = None diff --git a/app/services/portability/exporting.py b/app/services/portability/exporting.py index 0589498..a11eaef 100644 --- a/app/services/portability/exporting.py +++ b/app/services/portability/exporting.py @@ -56,11 +56,14 @@ async def export_user_data( db_session: AsyncSession, *, user_id: int, + scholar_profile_ids: list[int] | None = None, ) -> dict[str, Any]: - scholars_result = await db_session.execute( - select(ScholarProfile).where(ScholarProfile.user_id == user_id).order_by(ScholarProfile.id.asc()) - ) - publication_result = await db_session.execute( + scholar_query = select(ScholarProfile).where(ScholarProfile.user_id == user_id) + if scholar_profile_ids: + scholar_query = scholar_query.where(ScholarProfile.id.in_(scholar_profile_ids)) + scholars_result = await db_session.execute(scholar_query.order_by(ScholarProfile.id.asc())) + + pub_query = ( select( ScholarProfile.scholar_id, Publication.cluster_id, @@ -77,8 +80,13 @@ async def export_user_data( .join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id) .join(Publication, Publication.id == ScholarPublication.publication_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()] publications = [_serialize_export_publication(row) for row in publication_result.all()] return { diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py index 028e3fc..3496732 100644 --- a/app/services/scholars/application.py +++ b/app/services/scholars/application.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import Any from uuid import uuid4 from sqlalchemy.exc import IntegrityError @@ -27,10 +28,66 @@ from app.services.scholars.validators import ( 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__ = [ "SEARCH_COOLDOWN_REASON", "SEARCH_DISABLED_REASON", "ScholarServiceError", + "bulk_delete_scholars", + "bulk_toggle_scholars", "clear_profile_image_customization", "create_scholar_for_user", "delete_scholar", @@ -125,7 +182,11 @@ async def delete_scholar( _safe_remove_upload(upload_root, profile.profile_image_upload_path) 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( diff --git a/frontend/src/components/ui/AppConfirmModal.vue b/frontend/src/components/ui/AppConfirmModal.vue new file mode 100644 index 0000000..044ca59 --- /dev/null +++ b/frontend/src/components/ui/AppConfirmModal.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts index 2d76eac..7b25632 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts @@ -2,10 +2,144 @@ import { describe, expect, it } from "vitest"; import { mount } from "@vue/test-utils"; import ScholarBatchAdd from "./ScholarBatchAdd.vue"; +import { + parseScholarIds, + parseScholarTokens, + extractScholarIdFromUrl, + validateTokenAsId, +} from "./scholar-batch-parsing"; 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", () => { const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); expect(wrapper.text()).toContain("Add Scholar Profiles"); @@ -73,4 +207,18 @@ describe("ScholarBatchAdd", () => { const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } }); 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"); + }); }); diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.vue b/frontend/src/features/scholars/components/ScholarBatchAdd.vue index bfafbda..60f89b9 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.vue +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.vue @@ -3,6 +3,7 @@ import { computed, ref } from "vue"; import AppButton from "@/components/ui/AppButton.vue"; import AppCard from "@/components/ui/AppCard.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue"; +import { parseScholarTokens } from "./scholar-batch-parsing"; defineProps<{ saving: boolean; @@ -15,38 +16,13 @@ const emit = defineEmits<{ const scholarBatchInput = ref(""); -const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; -const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i; - -function parseScholarIds(raw: string): string[] { - const ordered: string[] = []; - const seen = new Set(); - 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); +const parsedTokens = computed(() => parseScholarTokens(scholarBatchInput.value)); +const validIds = computed(() => parsedTokens.value.filter((t) => t.id !== null)); +const invalidTokens = computed(() => parsedTokens.value.filter((t) => t.id === null)); +const parsedBatchCount = computed(() => validIds.value.length); function onSubmit(): void { - const ids = parseScholarIds(scholarBatchInput.value); + const ids = validIds.value.map((t) => t.id!); if (ids.length > 0) { emit("add-scholars", ids); scholarBatchInput.value = ""; @@ -76,11 +52,33 @@ function onSubmit(): void { /> -
+

- Parsed IDs: {{ parsedBatchCount }} + {{ parsedBatchCount }} valid ID{{ parsedBatchCount === 1 ? "" : "s" }} +

- +
    +
  • + #{{ item.index }}: {{ item.error }} + {{ item.raw.length > 60 ? item.raw.slice(0, 57) + "..." : item.raw }} +
  • +
  • + +{{ invalidTokens.length - 5 }} more skipped +
  • +
+
+
+ Parsed IDs: 0 +
+ +
+ {{ saving ? "Adding..." : "Add scholars" }}
diff --git a/frontend/src/features/scholars/components/ScholarSettingsModal.vue b/frontend/src/features/scholars/components/ScholarSettingsModal.vue index 4a129f8..77af0ac 100644 --- a/frontend/src/features/scholars/components/ScholarSettingsModal.vue +++ b/frontend/src/features/scholars/components/ScholarSettingsModal.vue @@ -47,7 +47,14 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer :image-url="scholar.profile_image_url" />
-

{{ scholarLabel(scholar) }}

+

+ {{ scholarLabel(scholar) }} + Pending +

ID: {{ scholar.scholar_id }}

diff --git a/frontend/src/features/scholars/components/ScholarStatusBadges.vue b/frontend/src/features/scholars/components/ScholarStatusBadges.vue new file mode 100644 index 0000000..86b2cd5 --- /dev/null +++ b/frontend/src/features/scholars/components/ScholarStatusBadges.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/features/scholars/components/scholar-batch-parsing.ts b/frontend/src/features/scholars/components/scholar-batch-parsing.ts new file mode 100644 index 0000000..45baba7 --- /dev/null +++ b/frontend/src/features/scholars/components/scholar-batch-parsing.ts @@ -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(); + + 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!); +} diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts new file mode 100644 index 0000000..5a8dff9 --- /dev/null +++ b/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts @@ -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); + }); +}); diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.ts new file mode 100644 index 0000000..51cace7 --- /dev/null +++ b/frontend/src/features/scholars/composables/useScholarBulkActions.ts @@ -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; +} + +export function useScholarBulkActions( + visibleScholars: Ref, + callbacks: BulkActionCallbacks, +) { + const selectedIds = ref>(new Set()); + const bulkAction = ref("select_all"); + const bulkBusy = ref(false); + const confirmState = ref({ 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(() => { + 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(); + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts index ef3c8c4..f6ad9d3 100644 --- a/frontend/src/features/scholars/index.ts +++ b/frontend/src/features/scholars/index.ts @@ -160,8 +160,30 @@ export async function clearScholarImage( return response.data; } -export async function exportScholarData(): Promise { - const response = await apiRequest("/scholars/export", { +export interface BulkCountResult { + deleted_count: number; + updated_count: number; +} + +export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise { + const response = await apiRequest("/scholars/bulk-delete", { + method: "POST", + body: { scholar_profile_ids: scholarProfileIds }, + }); + return response.data; +} + +export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise { + const response = await apiRequest("/scholars/bulk-toggle", { + method: "POST", + body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled }, + }); + return response.data; +} + +export async function exportScholarData(ids?: number[]): Promise { + const path = ids && ids.length > 0 ? `/scholars/export?ids=${ids.join(",")}` : "/scholars/export"; + const response = await apiRequest(path, { method: "GET", }); return response.data; diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index c281de1..d7ee685 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -25,7 +25,10 @@ const integrityRef = ref | null>(null const repairsRef = ref | null>(null); const pdfQueueRef = ref | null>(null); +let loadGeneration = 0; + async function loadSection(): Promise { + const gen = ++loadGeneration; clearAlerts(); try { if (props.section === SECTION_USERS && usersRef.value) { @@ -34,11 +37,13 @@ async function loadSection(): Promise { 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) { + if (gen !== loadGeneration) return; assignError(error, "Unable to load admin data."); } } diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index c7da221..3f7df35 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -494,6 +494,11 @@ watch(

@@ -527,6 +532,9 @@ watch(
{{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new + diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue index 16b2a96..3899888 100644 --- a/frontend/src/pages/ScholarsPage.vue +++ b/frontend/src/pages/ScholarsPage.vue @@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import AppButton from "@/components/ui/AppButton.vue"; import AppCard from "@/components/ui/AppCard.vue"; +import AppConfirmModal from "@/components/ui/AppConfirmModal.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppInput from "@/components/ui/AppInput.vue"; @@ -27,10 +28,12 @@ import { type ScholarProfile, type ScholarSearchCandidate, } from "@/features/scholars"; +import { useScholarBulkActions } from "@/features/scholars/composables/useScholarBulkActions"; import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue"; +import ScholarStatusBadges from "@/features/scholars/components/ScholarStatusBadges.vue"; import { ApiRequestError } from "@/lib/api/errors"; import { useRunStatusStore } from "@/stores/run_status"; @@ -270,102 +273,71 @@ async function onToggleScholar(): Promise { } } -async function onDeleteScholar(): Promise { +function onDeleteScholar(): void { const profile = activeScholarSettings.value; if (!profile) return; const label = scholarLabel(profile); - if (!window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`)) return; - activeScholarId.value = profile.id; - clearMessages(); - try { - await deleteScholar(profile.id); - successMessage.value = `${label} deleted.`; - activeScholarSettingsId.value = null; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to delete scholar."); - } finally { - activeScholarId.value = null; - } + bulk.requestConfirm( + `Delete ${label}?`, + "This removes all linked publications and queue data. This action cannot be undone.", + "danger", + async () => { + bulk.dismissConfirm(); + activeScholarId.value = profile.id; + clearMessages(); + try { + await deleteScholar(profile.id); + successMessage.value = `${label} deleted.`; + activeScholarSettingsId.value = null; + await loadScholars(); + } catch (error) { + assignError(error, "Unable to delete scholar."); + } finally { + activeScholarId.value = null; + } + }, + ); } async function onSaveImageUrl(): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; - const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim(); - if (!candidate) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } - imageSavingScholarId.value = profile.id; + const p = activeScholarSettings.value; + if (!p) return; + const url = (imageUrlDraftByScholarId.value[p.id] || "").trim(); + if (!url) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } + imageSavingScholarId.value = p.id; clearMessages(); - try { - await setScholarImageUrl(profile.id, candidate); - successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to update scholar image URL."); - } finally { - imageSavingScholarId.value = null; - } + try { await setScholarImageUrl(p.id, url); successMessage.value = `Image URL updated for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to update scholar image URL."); } + finally { imageSavingScholarId.value = null; } } async function onUploadImage(event: Event): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; + const p = activeScholarSettings.value; + if (!p) return; const input = event.target as HTMLInputElement | null; const file = input?.files?.[0] ?? null; if (!file) return; - imageUploadingScholarId.value = profile.id; + imageUploadingScholarId.value = p.id; clearMessages(); - try { - await uploadScholarImage(profile.id, file); - successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to upload scholar image."); - } finally { - imageUploadingScholarId.value = null; - if (input) input.value = ""; - } + try { await uploadScholarImage(p.id, file); successMessage.value = `Uploaded image for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to upload scholar image."); } + finally { imageUploadingScholarId.value = null; if (input) input.value = ""; } } async function onResetImage(): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; - imageSavingScholarId.value = profile.id; + const p = activeScholarSettings.value; + if (!p) return; + imageSavingScholarId.value = p.id; clearMessages(); - try { - await clearScholarImage(profile.id); - successMessage.value = `Image reset for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to reset scholar image."); - } finally { - imageSavingScholarId.value = null; - } + try { await clearScholarImage(p.id); successMessage.value = `Image reset for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to reset scholar image."); } + finally { imageSavingScholarId.value = null; } } // --- Import/export --- -function suggestExportFilename(exportedAt: string): string { - return `scholarr-export-${exportedAt.slice(0, 10) || "unknown-date"}.json`; -} - -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}.` - ); +function importSummary(r: DataImportResult): string { + 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}.`; } async function onExportData(): Promise { @@ -373,7 +345,13 @@ async function onExportData(): Promise { clearMessages(); try { 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."; } catch (error) { assignError(error, "Unable to export scholars and publications."); @@ -382,10 +360,6 @@ async function onExportData(): Promise { } } -function onOpenImportPicker(): void { - importFileInput.value?.click(); -} - async function onImportFileSelected(event: Event): Promise { const input = event.target as HTMLInputElement | null; const file = input?.files?.[0] ?? null; @@ -395,16 +369,12 @@ async function onImportFileSelected(event: Event): Promise { try { const raw = await file.text(); 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; if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) { throw new Error("Invalid import file: expected scholars[] and publications[] arrays."); } - const result = await importScholarData(payload); - successMessage.value = importSummary(result); + successMessage.value = importSummary(await importScholarData(payload)); await loadScholars(); } catch (error) { assignError(error, "Unable to import scholars and publications."); @@ -414,6 +384,15 @@ async function onImportFileSelected(event: Event): Promise { } } +// --- Bulk actions --- + +const bulk = useScholarBulkActions(visibleScholars, { + clearMessages, + assignError, + setSuccess: (msg: string) => { successMessage.value = msg; }, + reloadScholars: loadScholars, +}); + // --- Lifecycle --- onMounted(() => { void loadScholars(); }); @@ -471,7 +450,7 @@ watch( {{ exportingData ? "Exporting..." : "Export" }} - + {{ importingData ? "Importing..." : "Import" }} @@ -496,6 +475,34 @@ watch(
+
+ + {{ trackedCountLabel }} + + +
+ + + + + + {{ bulk.bulkApplyLabel.value }} + +
+
+
@@ -503,9 +510,19 @@ watch(
  • +
    -

    {{ scholarLabel(item) }}

    +

    + {{ scholarLabel(item) }} + +

    Publications Open profile @@ -519,17 +536,39 @@ watch( + + + Scholar Manage + + +
    - {{ scholarLabel(item) }} + + {{ scholarLabel(item) }} + +
    Publications Open profile @@ -566,5 +605,21 @@ watch( @toggle="onToggleScholar" @delete="onDeleteScholar" /> + + + + diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index e1c0e2b..3fb50fc 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -168,6 +168,11 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num 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) { @@ -175,7 +180,7 @@ function parseHoursToMinutes(value: string, minMinutes: number): number { } const minutes = Math.round(hours * 60); if (minutes < minMinutes) { - throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`); + throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`); } return minutes; } @@ -372,7 +377,7 @@ onMounted(async () => { - Minimum: {{ minCheckIntervalMinutes / 60 }} hours + Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours