Intermediate commit
This commit is contained in:
parent
0e9e49df16
commit
3d4cfeff1a
65 changed files with 5507 additions and 333 deletions
|
|
@ -12,15 +12,15 @@ const props = withDefaults(
|
|||
|
||||
const sizeClass = computed(() => {
|
||||
if (props.size === "sm") {
|
||||
return "h-5 w-5";
|
||||
return "h-7 w-7";
|
||||
}
|
||||
if (props.size === "lg") {
|
||||
return "h-8 w-8";
|
||||
return "h-10 w-10";
|
||||
}
|
||||
if (props.size === "xl") {
|
||||
return "h-12 w-12";
|
||||
return "h-14 w-14";
|
||||
}
|
||||
return "h-6 w-6";
|
||||
return "h-7 w-7";
|
||||
});
|
||||
|
||||
const logoMaskStyle: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,40 @@ export interface TriggerPublicationLinkRepairResult {
|
|||
summary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NearDuplicateClusterMember {
|
||||
publication_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
citation_count: number;
|
||||
}
|
||||
|
||||
export interface NearDuplicateCluster {
|
||||
cluster_key: string;
|
||||
winner_publication_id: number;
|
||||
member_count: number;
|
||||
similarity_score: number;
|
||||
members: NearDuplicateClusterMember[];
|
||||
}
|
||||
|
||||
export interface TriggerPublicationNearDuplicateRepairPayload {
|
||||
dry_run?: boolean;
|
||||
similarity_threshold?: number;
|
||||
min_shared_tokens?: number;
|
||||
max_year_delta?: number;
|
||||
max_clusters?: number;
|
||||
selected_cluster_keys?: string[];
|
||||
requested_by?: string;
|
||||
confirmation_text?: string;
|
||||
}
|
||||
|
||||
export interface TriggerPublicationNearDuplicateRepairResult {
|
||||
job_id: number;
|
||||
status: string;
|
||||
scope: Record<string, unknown>;
|
||||
summary: Record<string, unknown>;
|
||||
clusters: NearDuplicateCluster[];
|
||||
}
|
||||
|
||||
export async function getAdminDbIntegrityReport(): Promise<AdminDbIntegrityReport> {
|
||||
const response = await apiRequest<AdminDbIntegrityReport>("/admin/db/integrity", { method: "GET" });
|
||||
return response.data;
|
||||
|
|
@ -122,6 +156,19 @@ export async function triggerPublicationLinkRepair(
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export async function triggerPublicationNearDuplicateRepair(
|
||||
payload: TriggerPublicationNearDuplicateRepairPayload,
|
||||
): Promise<TriggerPublicationNearDuplicateRepairResult> {
|
||||
const response = await apiRequest<TriggerPublicationNearDuplicateRepairResult>(
|
||||
"/admin/db/repairs/publication-near-duplicates",
|
||||
{
|
||||
method: "POST",
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listAdminPdfQueue(
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export type PublicationMode = "all" | "unread" | "latest";
|
||||
export type PublicationSortBy =
|
||||
| "first_seen"
|
||||
| "title"
|
||||
| "year"
|
||||
| "citations"
|
||||
| "scholar"
|
||||
| "pdf_status";
|
||||
|
||||
export interface DisplayIdentifier {
|
||||
kind: string;
|
||||
|
|
@ -54,7 +61,7 @@ export interface PublicationsQuery {
|
|||
favoriteOnly?: boolean;
|
||||
scholarProfileId?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortBy?: PublicationSortBy;
|
||||
sortDir?: "asc" | "desc";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@ import {
|
|||
listAdminPdfQueue,
|
||||
requeueAdminPdfLookup,
|
||||
requeueAllAdminPdfLookups,
|
||||
triggerPublicationNearDuplicateRepair,
|
||||
triggerPublicationLinkRepair,
|
||||
type AdminDbIntegrityCheck,
|
||||
type AdminDbIntegrityReport,
|
||||
type AdminDbRepairJob,
|
||||
type AdminPdfQueueItem,
|
||||
type NearDuplicateCluster,
|
||||
type TriggerPublicationNearDuplicateRepairResult,
|
||||
type TriggerPublicationLinkRepairResult,
|
||||
} from "@/features/admin_dbops";
|
||||
import {
|
||||
|
|
@ -43,6 +46,7 @@ const SECTION_PDF = "pdf";
|
|||
const SCOPE_SINGLE_USER = "single_user";
|
||||
const SCOPE_ALL_USERS = "all_users";
|
||||
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
|
||||
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
|
||||
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
|
||||
|
||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||
|
|
@ -82,6 +86,17 @@ const repairRequestedBy = ref("");
|
|||
const repairDryRun = ref(true);
|
||||
const repairGcOrphans = ref(false);
|
||||
const repairConfirmationText = ref("");
|
||||
const runningNearDuplicateScan = ref(false);
|
||||
const applyingNearDuplicateRepair = ref(false);
|
||||
const nearDuplicateRequestedBy = ref("");
|
||||
const nearDuplicateSimilarityThreshold = ref("0.78");
|
||||
const nearDuplicateMinSharedTokens = ref("3");
|
||||
const nearDuplicateMaxYearDelta = ref("1");
|
||||
const nearDuplicateMaxClusters = ref("25");
|
||||
const nearDuplicateConfirmationText = ref("");
|
||||
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
|
||||
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
|
||||
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
|
||||
|
||||
const droppingPublications = ref(false);
|
||||
const dropConfirmationText = ref("");
|
||||
|
|
@ -105,6 +120,7 @@ const activeUser = computed(() => users.value.find((user) => user.id === activeU
|
|||
const typedConfirmationRequired = computed(
|
||||
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
|
||||
);
|
||||
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
|
||||
const pdfQueuePageSizeValue = computed(() => {
|
||||
const parsed = Number(pdfQueuePageSize.value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
|
@ -213,6 +229,64 @@ function validateTypedConfirmation(): string {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function parseBoundedNumber(
|
||||
raw: string,
|
||||
options: { minimum: number; maximum: number; fallback: number },
|
||||
): number {
|
||||
const { minimum, maximum, fallback } = options;
|
||||
const parsed = Number(raw.trim());
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(minimum, Math.min(maximum, parsed));
|
||||
}
|
||||
|
||||
function nearDuplicatePayloadBase(): {
|
||||
similarity_threshold: number;
|
||||
min_shared_tokens: number;
|
||||
max_year_delta: number;
|
||||
max_clusters: number;
|
||||
requested_by: string | undefined;
|
||||
} {
|
||||
return {
|
||||
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, {
|
||||
minimum: 0.5,
|
||||
maximum: 1.0,
|
||||
fallback: 0.78,
|
||||
}),
|
||||
min_shared_tokens: Math.trunc(
|
||||
parseBoundedNumber(nearDuplicateMinSharedTokens.value, {
|
||||
minimum: 1,
|
||||
maximum: 8,
|
||||
fallback: 3,
|
||||
}),
|
||||
),
|
||||
max_year_delta: Math.trunc(
|
||||
parseBoundedNumber(nearDuplicateMaxYearDelta.value, {
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
fallback: 1,
|
||||
}),
|
||||
),
|
||||
max_clusters: Math.trunc(
|
||||
parseBoundedNumber(nearDuplicateMaxClusters.value, {
|
||||
minimum: 1,
|
||||
maximum: 200,
|
||||
fallback: 25,
|
||||
}),
|
||||
),
|
||||
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedNearDuplicateKeys(): string[] {
|
||||
return [...nearDuplicateSelectedClusterKeys.value].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function checkboxEventChecked(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement ? event.target.checked : false;
|
||||
}
|
||||
|
||||
function summaryCount(job: AdminDbRepairJob, key: string): string {
|
||||
const value = job.summary[key];
|
||||
return typeof value === "number" ? String(value) : "n/a";
|
||||
|
|
@ -384,6 +458,67 @@ async function onRunRepair(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
|
||||
const next = new Set(nearDuplicateSelectedClusterKeys.value);
|
||||
if (checked) {
|
||||
next.add(clusterKey);
|
||||
} else {
|
||||
next.delete(clusterKey);
|
||||
}
|
||||
nearDuplicateSelectedClusterKeys.value = next;
|
||||
}
|
||||
|
||||
async function onRunNearDuplicateScan(): Promise<void> {
|
||||
runningNearDuplicateScan.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await triggerPublicationNearDuplicateRepair({
|
||||
dry_run: true,
|
||||
...nearDuplicatePayloadBase(),
|
||||
});
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
successMessage.value = `Near-duplicate scan completed (job #${result.job_id}).`;
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to scan for near-duplicate publications.");
|
||||
} finally {
|
||||
runningNearDuplicateScan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onApplyNearDuplicateRepair(): Promise<void> {
|
||||
applyingNearDuplicateRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const selectedKeys = selectedNearDuplicateKeys();
|
||||
if (selectedKeys.length === 0) {
|
||||
throw new Error("Select at least one near-duplicate cluster before applying.");
|
||||
}
|
||||
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
|
||||
}
|
||||
const result = await triggerPublicationNearDuplicateRepair({
|
||||
dry_run: false,
|
||||
selected_cluster_keys: selectedKeys,
|
||||
confirmation_text: nearDuplicateConfirmationText.value.trim(),
|
||||
...nearDuplicatePayloadBase(),
|
||||
});
|
||||
nearDuplicateClusters.value = result.clusters;
|
||||
nearDuplicateSelectedClusterKeys.value = new Set();
|
||||
nearDuplicateConfirmationText.value = "";
|
||||
lastNearDuplicateResult.value = result;
|
||||
successMessage.value = `Merged selected near-duplicate clusters (job #${result.job_id}).`;
|
||||
await refreshRepairJobs();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to apply near-duplicate merge.");
|
||||
} finally {
|
||||
applyingNearDuplicateRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScopeModeChange(): void {
|
||||
ensureRepairUserSelected();
|
||||
}
|
||||
|
|
@ -672,6 +807,98 @@ watch(
|
|||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
|
||||
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Similarity threshold</span>
|
||||
<AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Min shared tokens</span>
|
||||
<AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Max year delta</span>
|
||||
<AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Max preview clusters</span>
|
||||
<AppInput v-model="nearDuplicateMaxClusters" placeholder="25" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Requested by (optional)</span>
|
||||
<AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" />
|
||||
</label>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningNearDuplicateScan">
|
||||
{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
|
||||
<AppTable label="Near duplicate clusters table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Select</th>
|
||||
<th scope="col">Cluster</th>
|
||||
<th scope="col">Winner</th>
|
||||
<th scope="col">Members</th>
|
||||
<th scope="col">Similarity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
|
||||
<td>
|
||||
<input
|
||||
:id="`near-dup-${cluster.cluster_key}`"
|
||||
class="h-4 w-4 rounded border-stroke-default bg-surface-card"
|
||||
type="checkbox"
|
||||
:checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)"
|
||||
@change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))"
|
||||
/>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
|
||||
<td>#{{ cluster.winner_publication_id }}</td>
|
||||
<td>
|
||||
<div class="grid gap-1">
|
||||
<span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">
|
||||
#{{ member.publication_id }} · {{ member.title }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span>
|
||||
<AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" />
|
||||
</label>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">
|
||||
{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge>
|
||||
<span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span>
|
||||
</div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
|
@ -26,6 +26,8 @@ const refreshingAfterCompletion = ref(false);
|
|||
const auth = useAuthStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
|
||||
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const isStartBlocked = computed(
|
||||
() =>
|
||||
|
|
@ -145,13 +147,35 @@ function shouldRefreshAfterRunChange(
|
|||
if (!nextRun || !previousRun) {
|
||||
return false;
|
||||
}
|
||||
if (nextRun.status === "running") {
|
||||
return false;
|
||||
}
|
||||
if (nextRun.id !== previousRun.id) {
|
||||
return true;
|
||||
}
|
||||
return previousRun.status === "running";
|
||||
if (nextRun.status === previousRun.status) {
|
||||
return false;
|
||||
}
|
||||
const nextActive = nextRun.status === "running" || nextRun.status === "resolving";
|
||||
const previousActive = previousRun.status === "running" || previousRun.status === "resolving";
|
||||
return nextActive || previousActive;
|
||||
}
|
||||
|
||||
function startRunStatusSyncLoop(): void {
|
||||
if (runStatusSyncTimer !== null) {
|
||||
return;
|
||||
}
|
||||
runStatusSyncTimer = setInterval(() => {
|
||||
if (runStatus.isRunActive) {
|
||||
return;
|
||||
}
|
||||
void runStatus.syncLatest();
|
||||
}, DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopRunStatusSyncLoop(): void {
|
||||
if (runStatusSyncTimer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(runStatusSyncTimer);
|
||||
runStatusSyncTimer = null;
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
|
|
@ -228,10 +252,15 @@ async function onCancelRun(): Promise<void> {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
startRunStatusSyncLoop();
|
||||
void loadSnapshot();
|
||||
void runStatus.syncLatest();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRunStatusSyncLoop();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => runStatus.latestRun,
|
||||
(nextRun, previousRun) => {
|
||||
|
|
@ -372,7 +401,7 @@ watch(
|
|||
<AppButton
|
||||
v-if="auth.isAdmin && runStatus.isLikelyRunning"
|
||||
variant="danger"
|
||||
:disabled="!activeRunId || isCancelAnimating.value"
|
||||
:disabled="!activeRunId || isCancelAnimating"
|
||||
@click="onCancelRun"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import AppPage from "@/components/layout/AppPage.vue";
|
||||
|
|
@ -33,7 +33,8 @@ type PublicationSortKey =
|
|||
| "scholar"
|
||||
| "year"
|
||||
| "citations"
|
||||
| "first_seen";
|
||||
| "first_seen"
|
||||
| "pdf_status";
|
||||
|
||||
type BulkAction =
|
||||
| "mark_selected_read"
|
||||
|
|
@ -70,6 +71,8 @@ const router = useRouter();
|
|||
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
|
||||
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function normalizeScholarFilterQuery(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -179,6 +182,26 @@ function publicationIdentifierLabel(item: PublicationItem): string | null {
|
|||
return item.display_identifier?.label ?? null;
|
||||
}
|
||||
|
||||
function startRunStatusSyncLoop(): void {
|
||||
if (runStatusSyncTimer !== null) {
|
||||
return;
|
||||
}
|
||||
runStatusSyncTimer = setInterval(() => {
|
||||
if (runStatus.isRunActive) {
|
||||
return;
|
||||
}
|
||||
void runStatus.syncLatest();
|
||||
}, PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopRunStatusSyncLoop(): void {
|
||||
if (runStatusSyncTimer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(runStatusSyncTimer);
|
||||
runStatusSyncTimer = null;
|
||||
}
|
||||
|
||||
const selectedScholarName = computed(() => {
|
||||
const selectedId = Number(selectedScholarFilter.value);
|
||||
if (!Number.isInteger(selectedId) || selectedId <= 0) {
|
||||
|
|
@ -203,11 +226,12 @@ const filteredPublications = computed(() => {
|
|||
|
||||
const base = listState.value?.publications ?? [];
|
||||
const merged = [...stream, ...base];
|
||||
const seenIds = new Set();
|
||||
const seenKeys = new Set<string>();
|
||||
const deduped: typeof base = [];
|
||||
for (const item of merged) {
|
||||
if (!seenIds.has(item.publication_id)) {
|
||||
seenIds.add(item.publication_id);
|
||||
const key = publicationKey(item);
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -234,14 +258,41 @@ function publicationSortValue(item: PublicationItem, key: PublicationSortKey): n
|
|||
if (key === "citations") {
|
||||
return item.citation_count;
|
||||
}
|
||||
if (key === "pdf_status") {
|
||||
if (item.pdf_url || item.pdf_status === "resolved") {
|
||||
return 4;
|
||||
}
|
||||
if (item.pdf_status === "running") {
|
||||
return 3;
|
||||
}
|
||||
if (item.pdf_status === "queued") {
|
||||
return 2;
|
||||
}
|
||||
if (item.pdf_status === "failed") {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
const timestamp = Date.parse(item.first_seen_at);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
const sortedPublications = computed(() => {
|
||||
// Server already returns data in the correct sort order.
|
||||
// We just pass through the filtered/merged list without client-side re-sorting.
|
||||
return filteredPublications.value;
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...filteredPublications.value].sort((left, right) => {
|
||||
const leftValue = publicationSortValue(left, sortKey.value);
|
||||
const rightValue = publicationSortValue(right, sortKey.value);
|
||||
let comparison = 0;
|
||||
if (typeof leftValue === "string" && typeof rightValue === "string") {
|
||||
comparison = textCollator.compare(leftValue, rightValue);
|
||||
} else {
|
||||
comparison = Number(leftValue) - Number(rightValue);
|
||||
}
|
||||
if (comparison !== 0) {
|
||||
return comparison * direction;
|
||||
}
|
||||
return right.publication_id - left.publication_id;
|
||||
});
|
||||
});
|
||||
|
||||
const visibleUnreadKeys = computed(() => {
|
||||
|
|
@ -271,6 +322,7 @@ const totalPages = computed(() => {
|
|||
});
|
||||
|
||||
const selectedCount = computed(() => selectedPublicationKeys.value.size);
|
||||
const totalCount = computed(() => listState.value?.total_count ?? 0);
|
||||
const visibleCount = computed(() => sortedPublications.value.length);
|
||||
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
|
||||
const visibleFavoriteCount = computed(
|
||||
|
|
@ -432,7 +484,7 @@ async function toggleSort(nextKey: PublicationSortKey): Promise<void> {
|
|||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
|
||||
sortDirection.value = nextKey === "first_seen" || nextKey === "pdf_status" ? "desc" : "asc";
|
||||
}
|
||||
currentPage.value = 1;
|
||||
publicationSnapshot.value = null;
|
||||
|
|
@ -800,9 +852,32 @@ watch(searchQuery, () => {
|
|||
|
||||
onMounted(() => {
|
||||
syncFiltersFromRoute();
|
||||
startRunStatusSyncLoop();
|
||||
void Promise.all([loadScholarFilters(), loadPublications(), runStatus.syncLatest()]);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRunStatusSyncLoop();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => runStatus.latestRun,
|
||||
async (nextRun, previousRun) => {
|
||||
const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving")
|
||||
? nextRun.id
|
||||
: null;
|
||||
const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving")
|
||||
? previousRun.id
|
||||
: null;
|
||||
if (nextRunId === null || nextRunId === previousRunId) {
|
||||
return;
|
||||
}
|
||||
publicationSnapshot.value = null;
|
||||
currentPage.value = 1;
|
||||
await loadPublications();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [route.query.scholar, route.query.favorite, route.query.page],
|
||||
async () => {
|
||||
|
|
@ -1008,7 +1083,11 @@ watch(
|
|||
Scholar <span aria-hidden="true" class="sort-marker">{{ sortMarker('scholar') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-[8.5rem] whitespace-nowrap text-left font-semibold text-ink-primary">PDF</th>
|
||||
<th scope="col" class="w-[8.5rem] whitespace-nowrap">
|
||||
<button type="button" class="table-sort" @click="toggleSort('pdf_status')">
|
||||
PDF <span aria-hidden="true" class="sort-marker">{{ sortMarker('pdf_status') }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="w-16 whitespace-nowrap">
|
||||
<button type="button" class="table-sort" @click="toggleSort('year')">
|
||||
Year <span aria-hidden="true" class="sort-marker">{{ sortMarker('year') }}</span>
|
||||
|
|
@ -1135,7 +1214,7 @@ watch(
|
|||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-2 text-xs text-secondary">
|
||||
<span>
|
||||
visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
|
||||
total {{ totalCount }} · visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
|
||||
· selected {{ selectedCount }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import AppPage from "@/components/layout/AppPage.vue";
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from "@/features/scholars";
|
||||
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
|
@ -66,6 +67,10 @@ const successMessage = ref<string | null>(null);
|
|||
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
||||
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
||||
const nameSearchWip = true;
|
||||
const SCHOLARS_LIVE_SYNC_INTERVAL_MS = 4000;
|
||||
let scholarsLiveSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const runStatus = useRunStatusStore();
|
||||
|
||||
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
|
||||
const activeScholarSettings = computed(
|
||||
|
|
@ -260,14 +265,54 @@ function syncImageDrafts(): void {
|
|||
imageUrlDraftByScholarId.value = next;
|
||||
}
|
||||
|
||||
function applyScholarList(nextScholars: ScholarProfile[]): void {
|
||||
scholars.value = nextScholars;
|
||||
syncImageDrafts();
|
||||
}
|
||||
|
||||
function upsertScholar(profile: ScholarProfile): void {
|
||||
const existingIndex = scholars.value.findIndex((item) => item.id === profile.id);
|
||||
if (existingIndex < 0) {
|
||||
applyScholarList([profile, ...scholars.value]);
|
||||
return;
|
||||
}
|
||||
const next = [...scholars.value];
|
||||
next[existingIndex] = profile;
|
||||
applyScholarList(next);
|
||||
}
|
||||
|
||||
function stopScholarLiveSync(): void {
|
||||
if (scholarsLiveSyncTimer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(scholarsLiveSyncTimer);
|
||||
scholarsLiveSyncTimer = null;
|
||||
}
|
||||
|
||||
function startScholarLiveSync(): void {
|
||||
if (scholarsLiveSyncTimer !== null) {
|
||||
return;
|
||||
}
|
||||
scholarsLiveSyncTimer = setInterval(() => {
|
||||
void refreshScholarsSilently();
|
||||
}, SCHOLARS_LIVE_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function refreshScholarsSilently(): Promise<void> {
|
||||
try {
|
||||
applyScholarList(await listScholars());
|
||||
} catch (_error) {
|
||||
// Keep existing list when transient refresh fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScholars(): Promise<void> {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
scholars.value = await listScholars();
|
||||
syncImageDrafts();
|
||||
applyScholarList(await listScholars());
|
||||
} catch (error) {
|
||||
scholars.value = [];
|
||||
applyScholarList([]);
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
errorRequestId.value = error.requestId;
|
||||
|
|
@ -360,7 +405,11 @@ async function onAddScholars(): Promise<void> {
|
|||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
scholarIds.map((scholarId) => createScholar({ scholar_id: scholarId })),
|
||||
scholarIds.map(async (scholarId) => {
|
||||
const created = await createScholar({ scholar_id: scholarId });
|
||||
upsertScholar(created);
|
||||
return created;
|
||||
}),
|
||||
);
|
||||
|
||||
const failures: string[] = [];
|
||||
|
|
@ -399,7 +448,7 @@ async function onAddScholars(): Promise<void> {
|
|||
errorRequestId.value = requestIdFromFailures;
|
||||
}
|
||||
|
||||
await loadScholars();
|
||||
await refreshScholarsSilently();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
|
|
@ -453,12 +502,12 @@ async function onAddCandidate(candidate: ScholarSearchCandidate): Promise<void>
|
|||
successMessage.value = null;
|
||||
|
||||
try {
|
||||
await createScholar({
|
||||
const created = await createScholar({
|
||||
scholar_id: candidate.scholar_id,
|
||||
profile_image_url: candidate.profile_image_url ?? undefined,
|
||||
});
|
||||
upsertScholar(created);
|
||||
successMessage.value = `${candidate.display_name} added.`;
|
||||
await loadScholars();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
|
|
@ -608,6 +657,22 @@ async function onResetImage(profile: ScholarProfile): Promise<void> {
|
|||
onMounted(() => {
|
||||
void loadScholars();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScholarLiveSync();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => runStatus.isLikelyRunning,
|
||||
(isRunning) => {
|
||||
if (isRunning) {
|
||||
startScholarLiveSync();
|
||||
return;
|
||||
}
|
||||
stopScholarLiveSync();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,35 @@ function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
|
|||
};
|
||||
}
|
||||
|
||||
class FakeEventSource {
|
||||
static instances: FakeEventSource[] = [];
|
||||
|
||||
public readonly url: string;
|
||||
public closed = false;
|
||||
private listeners = new Map<string, Array<(event: { data: string }) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(eventType: string, callback: (event: { data: string }) => void): void {
|
||||
const existing = this.listeners.get(eventType) ?? [];
|
||||
this.listeners.set(eventType, [...existing, callback]);
|
||||
}
|
||||
|
||||
emit(eventType: string, payload: unknown): void {
|
||||
const callbacks = this.listeners.get(eventType) ?? [];
|
||||
for (const callback of callbacks) {
|
||||
callback({ data: JSON.stringify(payload) });
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("run status store", () => {
|
||||
const mockedListRuns = vi.mocked(listRuns);
|
||||
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
|
||||
|
|
@ -309,4 +338,42 @@ describe("run status store", () => {
|
|||
|
||||
expect(store.latestRun?.new_publication_count).toBe(5);
|
||||
});
|
||||
|
||||
it("applies identifier_updated SSE events to live publications", () => {
|
||||
const previousEventSource = (globalThis as any).EventSource;
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource as any;
|
||||
try {
|
||||
const store = useRunStatusStore();
|
||||
store.setLatestRun(buildRun({ id: 314, status: "running", end_dt: null }));
|
||||
|
||||
const stream = FakeEventSource.instances[0];
|
||||
expect(stream).toBeDefined();
|
||||
stream.emit("publication_discovered", {
|
||||
publication_id: 22,
|
||||
scholar_profile_id: 7,
|
||||
scholar_label: "Ada Lovelace",
|
||||
title: "Optimization Notes",
|
||||
pub_url: null,
|
||||
first_seen_at: "2026-02-26T10:00:00Z",
|
||||
});
|
||||
expect(store.livePublications).toHaveLength(1);
|
||||
expect(store.livePublications[0].display_identifier).toBeNull();
|
||||
|
||||
stream.emit("identifier_updated", {
|
||||
publication_id: 22,
|
||||
display_identifier: {
|
||||
kind: "doi",
|
||||
value: "10.1000/xyz",
|
||||
label: "DOI: 10.1000/xyz",
|
||||
url: "https://doi.org/10.1000/xyz",
|
||||
confidence_score: 0.95,
|
||||
},
|
||||
});
|
||||
expect(store.livePublications[0].display_identifier?.kind).toBe("doi");
|
||||
expect(store.livePublications[0].display_identifier?.value).toBe("10.1000/xyz");
|
||||
} finally {
|
||||
(globalThis as any).EventSource = previousEventSource;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ let eventSource: EventSource | null = null;
|
|||
let activeStreamRunId: number | null = null;
|
||||
const ACTIVE_STATUSES = new Set(["running", "resolving"]);
|
||||
|
||||
type StreamDisplayIdentifier = PublicationItem["display_identifier"];
|
||||
|
||||
function parseRunId(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
|
|
@ -86,6 +88,46 @@ function parsePublicationCount(value: unknown, fallback: number): number {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
function parseDisplayIdentifier(value: unknown): StreamDisplayIdentifier {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const payload = value as Record<string, unknown>;
|
||||
if (typeof payload.kind !== "string" || typeof payload.value !== "string" || typeof payload.label !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (typeof payload.confidence_score !== "number" || !Number.isFinite(payload.confidence_score)) {
|
||||
return null;
|
||||
}
|
||||
const url = typeof payload.url === "string" ? payload.url : null;
|
||||
return {
|
||||
kind: payload.kind,
|
||||
value: payload.value,
|
||||
label: payload.label,
|
||||
url,
|
||||
confidence_score: payload.confidence_score,
|
||||
};
|
||||
}
|
||||
|
||||
function withUpdatedDisplayIdentifier(
|
||||
items: PublicationItem[],
|
||||
update: {
|
||||
publicationId: number;
|
||||
displayIdentifier: StreamDisplayIdentifier;
|
||||
},
|
||||
): PublicationItem[] {
|
||||
const { publicationId, displayIdentifier } = update;
|
||||
let changed = false;
|
||||
const next = items.map((item) => {
|
||||
if (item.publication_id !== publicationId) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return { ...item, display_identifier: displayIdentifier };
|
||||
});
|
||||
return changed ? next : items;
|
||||
}
|
||||
|
||||
function reconcileRunCounters(previous: RunListItem | null, next: RunListItem | null): RunListItem | null {
|
||||
if (previous === null || next === null) {
|
||||
return next;
|
||||
|
|
@ -200,7 +242,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
this.updateEventSource();
|
||||
},
|
||||
updateEventSource(): void {
|
||||
const targetRunId = this.latestRun?.status === "running" ? this.latestRun.id : null;
|
||||
const targetRunId = isActiveStatus(this.latestRun?.status) ? this.latestRun?.id ?? null : null;
|
||||
if (activeStreamRunId === targetRunId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -251,6 +293,25 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
console.error("Failed to parse SSE event", err);
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("identifier_updated", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
const publicationId = parseRunId(data?.publication_id);
|
||||
const displayIdentifier = parseDisplayIdentifier(data?.display_identifier);
|
||||
if (publicationId === null || displayIdentifier === null) {
|
||||
return;
|
||||
}
|
||||
this.livePublications = withUpdatedDisplayIdentifier(
|
||||
this.livePublications,
|
||||
{
|
||||
publicationId,
|
||||
displayIdentifier,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse SSE event", err);
|
||||
}
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
// Reconnecting is handled automatically by EventSource,
|
||||
// but if it's permanently closed, we could do something here.
|
||||
|
|
@ -429,6 +490,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
this.lastErrorRequestId = null;
|
||||
this.lastSyncAt = null;
|
||||
this.safetyState = createDefaultSafetyState();
|
||||
this.livePublications = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue