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 @@
-
-
-
- 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
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(
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` : "" }}.
-
-
@@ -532,9 +527,6 @@ watch(
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