feat: refactor backend services and add direct PDF links, import/export, and dashboard sync
This commit is contained in:
parent
ba7976d935
commit
7f7a8ce2b0
26 changed files with 4170 additions and 2440 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue