feat: refactor backend services and add direct PDF links, import/export, and dashboard sync

This commit is contained in:
Justin Visser 2026-02-19 23:45:52 +01:00
parent ba7976d935
commit 7f7a8ce2b0
26 changed files with 4170 additions and 2440 deletions

View file

@ -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";
@ -14,7 +14,7 @@ import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { RUN_STATUS_POLL_INTERVAL_MS, useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
const loading = ref(true);
@ -26,6 +26,7 @@ const refreshingAfterCompletion = ref(false);
const auth = useAuthStore();
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
let latestRunSyncTimer: ReturnType<typeof setInterval> | null = null;
const isStartBlocked = computed(
() =>
@ -118,6 +119,39 @@ function formatDate(value: string | null): string {
return asDate.toLocaleString();
}
function shouldRefreshAfterRunChange(
nextRun: typeof runStatus.latestRun,
previousRun: typeof runStatus.latestRun,
): boolean {
if (!nextRun || !previousRun) {
return false;
}
if (nextRun.status === "running") {
return false;
}
if (nextRun.id !== previousRun.id) {
return true;
}
return previousRun.status === "running";
}
function startLatestRunSyncLoop(): void {
if (latestRunSyncTimer !== null) {
return;
}
latestRunSyncTimer = setInterval(() => {
void runStatus.syncLatest();
}, RUN_STATUS_POLL_INTERVAL_MS);
}
function stopLatestRunSyncLoop(): void {
if (latestRunSyncTimer === null) {
return;
}
clearInterval(latestRunSyncTimer);
latestRunSyncTimer = null;
}
async function loadSnapshot(): Promise<void> {
loading.value = true;
errorMessage.value = null;
@ -172,6 +206,12 @@ async function onTriggerRun(): Promise<void> {
onMounted(() => {
void loadSnapshot();
void runStatus.syncLatest();
startLatestRunSyncLoop();
});
onUnmounted(() => {
stopLatestRunSyncLoop();
});
watch(
@ -180,13 +220,7 @@ watch(
if (refreshingAfterCompletion.value) {
return;
}
if (!nextRun || !previousRun) {
return;
}
if (nextRun.id !== previousRun.id) {
return;
}
if (previousRun.status !== "running" || nextRun.status === "running") {
if (!shouldRefreshAfterRunChange(nextRun, previousRun)) {
return;
}

View file

@ -99,6 +99,10 @@ function scholarLabel(item: ScholarProfile): string {
return item.display_name || item.scholar_id;
}
function publicationPrimaryUrl(item: PublicationItem): string | null {
return item.pub_url || item.pdf_url;
}
const selectedScholarName = computed(() => {
const selectedId = Number(selectedScholarFilter.value);
if (!Number.isInteger(selectedId) || selectedId <= 0) {
@ -590,16 +594,27 @@ watch(
/>
</td>
<td>
<a
v-if="item.pub_url"
:href="item.pub_url"
target="_blank"
rel="noreferrer"
class="link-inline"
>
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
<div class="grid gap-1">
<a
v-if="publicationPrimaryUrl(item)"
:href="publicationPrimaryUrl(item) || ''"
target="_blank"
rel="noreferrer"
class="link-inline"
>
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
<a
v-if="item.pdf_url"
:href="item.pdf_url"
target="_blank"
rel="noreferrer"
class="link-inline text-xs"
>
Direct PDF
</a>
</div>
</td>
<td>{{ item.scholar_label }}</td>
<td>{{ item.year ?? "n/a" }}</td>
@ -607,7 +622,7 @@ watch(
<td>
<div class="flex flex-wrap items-center gap-2">
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
{{ item.is_new_in_latest_run ? "New this check" : "Seen before" }}
{{ item.is_new_in_latest_run ? "New" : "Seen before" }}
</AppBadge>
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
{{ item.is_read ? "Read" : "Unread" }}

View file

@ -17,11 +17,15 @@ import {
clearScholarImage,
createScholar,
deleteScholar,
exportScholarData,
importScholarData,
listScholars,
searchScholarsByName,
setScholarImageUrl,
toggleScholar,
uploadScholarImage,
type DataImportPayload,
type DataImportResult,
type ScholarProfile,
type ScholarSearchCandidate,
type ScholarSearchResult,
@ -37,6 +41,9 @@ const imageSavingScholarId = ref<number | null>(null);
const imageUploadingScholarId = ref<number | null>(null);
const addingCandidateScholarId = ref<string | null>(null);
const activeScholarSettingsId = ref<number | null>(null);
const importingData = ref(false);
const exportingData = ref(false);
const importFileInput = ref<HTMLInputElement | null>(null);
const scholars = ref<ScholarProfile[]>([]);
const imageUrlDraftByScholarId = ref<Record<number, string>>({});
@ -197,6 +204,30 @@ function scholarLabel(profile: ScholarProfile): string {
return profile.display_name || profile.scholar_id;
}
function suggestExportFilename(exportedAt: string): string {
const date = exportedAt.slice(0, 10) || "unknown-date";
return `scholarr-export-${date}.json`;
}
function downloadJsonFile(filename: string, payload: unknown): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function importSummary(result: DataImportResult): string {
return (
`Import complete. Scholars +${result.scholars_created}` +
` / updated ${result.scholars_updated}; publications +${result.publications_created}` +
` / updated ${result.publications_updated}; links +${result.links_created}` +
` / updated ${result.links_updated}; skipped ${result.skipped_records}.`
);
}
function isImageBusy(scholarProfileId: number): boolean {
return (
imageSavingScholarId.value === scholarProfileId ||
@ -246,6 +277,74 @@ async function loadScholars(): Promise<void> {
}
}
async function onExportData(): Promise<void> {
exportingData.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
const payload = await exportScholarData();
downloadJsonFile(suggestExportFilename(payload.exported_at), payload);
successMessage.value = "Export complete.";
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to export scholars and publications.";
}
} finally {
exportingData.value = false;
}
}
function onOpenImportPicker(): void {
importFileInput.value?.click();
}
function parseImportedJson(raw: string): DataImportPayload {
const parsed = JSON.parse(raw) as DataImportPayload;
if (!parsed || !Array.isArray(parsed.scholars) || !Array.isArray(parsed.publications)) {
throw new Error("Invalid import file: expected scholars[] and publications[] arrays.");
}
return parsed;
}
async function onImportFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null;
if (!file) {
return;
}
importingData.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
const payload = parseImportedJson(await file.text());
const result = await importScholarData(payload);
successMessage.value = importSummary(result);
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to import scholars and publications.";
}
} finally {
importingData.value = false;
if (input) {
input.value = "";
}
}
}
async function onAddScholars(): Promise<void> {
saving.value = true;
errorMessage.value = null;
@ -706,11 +805,24 @@ onMounted(() => {
>
{{ trackedCountLabel }}
</span>
<AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData">
{{ exportingData ? "Exporting..." : "Export" }}
</AppButton>
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker">
{{ importingData ? "Importing..." : "Import" }}
</AppButton>
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
</div>
<input
ref="importFileInput"
type="file"
class="sr-only"
accept=".json,application/json"
@change="onImportFileSelected"
/>
<div class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label class="grid gap-1 text-xs font-medium uppercase tracking-wide text-secondary" for="tracked-scholar-filter">