diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 048be70..ce63ea2 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, *, is_admin: bool = False) -> dict[str, object]: +def _serialize_pdf_queue_item(item) -> dict[str, object]: return { "publication_id": item.publication_id, "title": item.title, @@ -69,7 +69,7 @@ def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, obje "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 if is_admin else None, + "requested_by_email": item.requested_by_email, "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, is_admin=current_user.is_admin) for item in queue_page.items], + "items": [_serialize_pdf_queue_item(item) 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 2a0c276..7bbc642 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -23,9 +23,6 @@ from app.api.schemas import ( DataImportEnvelope, DataImportRequest, MessageEnvelope, - ScholarBulkCountEnvelope, - ScholarBulkIdsRequest, - ScholarBulkToggleRequest, ScholarCreateRequest, ScholarEnvelope, ScholarImageUrlUpdateRequest, @@ -45,22 +42,6 @@ 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, @@ -88,81 +69,16 @@ 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, @@ -345,18 +261,11 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - 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 + await scholar_service.delete_scholar( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) 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 bc1035d..91cc459 100644 --- a/app/api/schemas/scholars.py +++ b/app/api/schemas/scholars.py @@ -126,33 +126,6 @@ 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 a11eaef..0589498 100644 --- a/app/services/portability/exporting.py +++ b/app/services/portability/exporting.py @@ -56,14 +56,11 @@ async def export_user_data( db_session: AsyncSession, *, user_id: int, - scholar_profile_ids: list[int] | None = None, ) -> dict[str, Any]: - 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 = ( + 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( select( ScholarProfile.scholar_id, Publication.cluster_id, @@ -80,13 +77,8 @@ 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 3496732..028e3fc 100644 --- a/app/services/scholars/application.py +++ b/app/services/scholars/application.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from typing import Any from uuid import uuid4 from sqlalchemy.exc import IntegrityError @@ -28,66 +27,10 @@ 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", @@ -182,11 +125,7 @@ async def delete_scholar( _safe_remove_upload(upload_root, profile.profile_image_upload_path) await db_session.delete(profile) - 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 + await db_session.commit() async def hydrate_profile_metadata( diff --git a/frontend/src/components/ui/AppConfirmModal.vue b/frontend/src/components/ui/AppConfirmModal.vue deleted file mode 100644 index 044ca59..0000000 --- a/frontend/src/components/ui/AppConfirmModal.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts index 7b25632..2d76eac 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts @@ -2,144 +2,10 @@ 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("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)", () => { +describe("ScholarBatchAdd", () => { it("renders the heading and input", () => { const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); expect(wrapper.text()).toContain("Add Scholar Profiles"); @@ -207,18 +73,4 @@ describe("ScholarBatchAdd (component)", () => { 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 60f89b9..bfafbda 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.vue +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.vue @@ -3,7 +3,6 @@ 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; @@ -16,13 +15,38 @@ const emit = defineEmits<{ const scholarBatchInput = ref(""); -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); +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); function onSubmit(): void { - const ids = validIds.value.map((t) => t.id!); + const ids = parseScholarIds(scholarBatchInput.value); if (ids.length > 0) { emit("add-scholars", ids); scholarBatchInput.value = ""; @@ -52,33 +76,11 @@ function onSubmit(): void { /> -
+

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

-
    -
  • - #{{ 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 77af0ac..4a129f8 100644 --- a/frontend/src/features/scholars/components/ScholarSettingsModal.vue +++ b/frontend/src/features/scholars/components/ScholarSettingsModal.vue @@ -47,14 +47,7 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer :image-url="scholar.profile_image_url" />
-

- {{ scholarLabel(scholar) }} - Pending -

+

{{ scholarLabel(scholar) }}

ID: {{ scholar.scholar_id }}

diff --git a/frontend/src/features/scholars/components/ScholarStatusBadges.vue b/frontend/src/features/scholars/components/ScholarStatusBadges.vue deleted file mode 100644 index 86b2cd5..0000000 --- a/frontend/src/features/scholars/components/ScholarStatusBadges.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/frontend/src/features/scholars/components/scholar-batch-parsing.ts b/frontend/src/features/scholars/components/scholar-batch-parsing.ts deleted file mode 100644 index 45baba7..0000000 --- a/frontend/src/features/scholars/components/scholar-batch-parsing.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 5a8dff9..0000000 --- a/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// @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 deleted file mode 100644 index 51cace7..0000000 --- a/frontend/src/features/scholars/composables/useScholarBulkActions.ts +++ /dev/null @@ -1,221 +0,0 @@ -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 f6ad9d3..ef3c8c4 100644 --- a/frontend/src/features/scholars/index.ts +++ b/frontend/src/features/scholars/index.ts @@ -160,30 +160,8 @@ export async function clearScholarImage( return response.data; } -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, { +export async function exportScholarData(): Promise { + const response = await apiRequest("/scholars/export", { method: "GET", }); return response.data; diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index d7ee685..c281de1 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -25,10 +25,7 @@ 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) { @@ -37,13 +34,11 @@ 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 3f7df35..c7da221 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -494,11 +494,6 @@ watch(

@@ -532,9 +527,6 @@ 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 3899888..16b2a96 100644 --- a/frontend/src/pages/ScholarsPage.vue +++ b/frontend/src/pages/ScholarsPage.vue @@ -6,7 +6,6 @@ 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"; @@ -28,12 +27,10 @@ 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"; @@ -273,71 +270,102 @@ async function onToggleScholar(): Promise { } } -function onDeleteScholar(): void { +async function onDeleteScholar(): Promise { const profile = activeScholarSettings.value; if (!profile) return; const label = scholarLabel(profile); - 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; - } - }, - ); + 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; + } } async function onSaveImageUrl(): Promise { - 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; + 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; clearMessages(); - 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; } + 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; + } } async function onUploadImage(event: Event): Promise { - const p = activeScholarSettings.value; - if (!p) return; + const profile = activeScholarSettings.value; + if (!profile) return; const input = event.target as HTMLInputElement | null; const file = input?.files?.[0] ?? null; if (!file) return; - imageUploadingScholarId.value = p.id; + imageUploadingScholarId.value = profile.id; clearMessages(); - 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 = ""; } + 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 = ""; + } } async function onResetImage(): Promise { - const p = activeScholarSettings.value; - if (!p) return; - imageSavingScholarId.value = p.id; + const profile = activeScholarSettings.value; + if (!profile) return; + imageSavingScholarId.value = profile.id; clearMessages(); - 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; } + 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; + } } // --- Import/export --- -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}.`; +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}.` + ); } async function onExportData(): Promise { @@ -345,13 +373,7 @@ async function onExportData(): Promise { clearMessages(); try { const payload = await exportScholarData(); - 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); + downloadJsonFile(suggestExportFilename(payload.exported_at), payload); successMessage.value = "Export complete."; } catch (error) { assignError(error, "Unable to export scholars and publications."); @@ -360,6 +382,10 @@ 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; @@ -369,12 +395,16 @@ async function onImportFileSelected(event: Event): Promise { try { const raw = await file.text(); let parsed = JSON.parse(raw); - if (parsed?.data && Array.isArray(parsed.data.scholars)) parsed = parsed.data; + // Accept the full export envelope (with data/meta wrapper) + 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."); } - successMessage.value = importSummary(await importScholarData(payload)); + const result = await importScholarData(payload); + successMessage.value = importSummary(result); await loadScholars(); } catch (error) { assignError(error, "Unable to import scholars and publications."); @@ -384,15 +414,6 @@ 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(); }); @@ -450,7 +471,7 @@ watch( {{ exportingData ? "Exporting..." : "Export" }} - + {{ importingData ? "Importing..." : "Import" }} @@ -475,34 +496,6 @@ watch(
-
- - {{ trackedCountLabel }} - - -
- - - - - - {{ bulk.bulkApplyLabel.value }} - -
-
-
@@ -510,19 +503,9 @@ watch(
  • -
    -

    - {{ scholarLabel(item) }} - -

    +

    {{ scholarLabel(item) }}

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