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 @@
+
+
+
+ Pending
+ Failed
+ Partial
+ OK
+
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(
Processed {{ displayedLatestRun.scholar_count }} scholars and discovered
{{ displayedLatestRun.new_publication_count }} new publications.
+
+
+ {{ displayedLatestRun.failed_count > 0 ? `${displayedLatestRun.failed_count} failed` : "" }}{{ displayedLatestRun.failed_count > 0 && displayedLatestRun.partial_count > 0 ? ", " : "" }}{{ displayedLatestRun.partial_count > 0 ? `${displayedLatestRun.partial_count} partial` : "" }}.
+
+
@@ -527,6 +532,9 @@ watch(
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