Compare commits
No commits in common. "9ee45463a8d7dc7f94cff369263a03463c8cfd78" and "596731a8a7104045ba16c9cca2977b3a392e1f3a" have entirely different histories.
9ee45463a8
...
596731a8a7
@ -6,19 +6,7 @@ from submissions.schemas import UserInfoOut
|
|||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
title="Opus Magnum Submission API",
|
title="Opus Magnum Submission API",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="""API for managing Opus Magnum puzzle submissions.
|
description="API for managing Opus Magnum puzzle submissions",
|
||||||
|
|
||||||
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
|
|
||||||
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
|
|
||||||
""",
|
|
||||||
openapi_extra={
|
|
||||||
"info": {
|
|
||||||
"contact": {
|
|
||||||
"name": "Legrems",
|
|
||||||
"email": "loic.gremaud@polylan.ch",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add authentication for protected endpoints
|
# Add authentication for protected endpoints
|
||||||
@ -45,7 +33,8 @@ def api_info(request):
|
|||||||
"description": "API for managing puzzle submissions with OCR validation",
|
"description": "API for managing puzzle submissions with OCR validation",
|
||||||
"features": [
|
"features": [
|
||||||
"Multi-puzzle submissions",
|
"Multi-puzzle submissions",
|
||||||
"OCR validation",
|
"File upload to S3",
|
||||||
|
"OCR validation tracking",
|
||||||
"Manual validation workflow",
|
"Manual validation workflow",
|
||||||
"Admin validation tools",
|
"Admin validation tools",
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,112 +1,151 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed, defineProps } from 'vue'
|
||||||
import PuzzleCard from "@/components/PuzzleCard.vue";
|
import PuzzleCard from '@/components/PuzzleCard.vue'
|
||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
import SubmissionForm from '@/components/SubmissionForm.vue'
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
import AdminPanel from '@/components/AdminPanel.vue'
|
||||||
import { apiService, errorHelpers } from "@/services/apiService";
|
import { apiService, errorHelpers } from '@/services/apiService'
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
import { useSubmissionsStore } from '@/stores/submissions'
|
||||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
import type { SteamCollection, PuzzleResponse, UserInfo } from '@/types'
|
||||||
import { useCountdown } from "@vueuse/core";
|
import { useCountdown } from '@vueuse/core'
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{collectionTitle: string, collectionUrl: string, collectionDescription: string}>()
|
||||||
collectionTitle: string;
|
|
||||||
collectionUrl: string;
|
|
||||||
collectionDescription: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const puzzlesStore = usePuzzlesStore();
|
// Pinia stores
|
||||||
const submissionsStore = useSubmissionsStore();
|
const puzzlesStore = usePuzzlesStore()
|
||||||
|
const submissionsStore = useSubmissionsStore()
|
||||||
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
|
|
||||||
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
|
||||||
submissionsStore;
|
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const userInfo = ref<UserInfo | null>(null);
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true)
|
||||||
const error = ref<string>("");
|
const error = ref<string>('')
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const isSuperuser = computed(() => {
|
const isSuperuser = computed(() => {
|
||||||
return userInfo.value?.is_superuser || false;
|
return userInfo.value?.is_superuser || false
|
||||||
});
|
})
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
// Computed property to get responses grouped by puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
const grouped: Record<number, PuzzleResponse[]> = {}
|
||||||
submissions.value.forEach((submission) => {
|
submissionsStore.submissions.forEach(submission => {
|
||||||
submission.responses.forEach((response) => {
|
submission.responses.forEach(response => {
|
||||||
// Handle both number and object types for puzzle field
|
// Handle both number and object types for puzzle field
|
||||||
if (!grouped[response.puzzle]) {
|
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
||||||
grouped[response.puzzle] = [];
|
if (!grouped[puzzleId]) {
|
||||||
|
grouped[puzzleId] = []
|
||||||
}
|
}
|
||||||
grouped[response.puzzle].push(response);
|
grouped[puzzleId].push(response)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
return grouped;
|
return grouped
|
||||||
});
|
})
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true
|
||||||
error.value = "";
|
error.value = ''
|
||||||
|
|
||||||
console.log("Starting data load...");
|
console.log('Starting data load...')
|
||||||
|
|
||||||
// Load user info
|
// Load user info
|
||||||
console.log("Loading user info...");
|
console.log('Loading user info...')
|
||||||
const userResponse = await apiService.getUserInfo();
|
const userResponse = await apiService.getUserInfo()
|
||||||
if (userResponse.data) {
|
if (userResponse.data) {
|
||||||
userInfo.value = userResponse.data;
|
userInfo.value = userResponse.data
|
||||||
console.log("User info loaded:", userResponse.data);
|
console.log('User info loaded:', userResponse.data)
|
||||||
} else if (userResponse.error) {
|
} else if (userResponse.error) {
|
||||||
console.warn("User info error:", userResponse.error);
|
console.warn('User info error:', userResponse.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load puzzles from API using store
|
// Load puzzles from API using store
|
||||||
console.log("Loading puzzles...");
|
console.log('Loading puzzles...')
|
||||||
await puzzlesStore.loadPuzzles();
|
await puzzlesStore.loadPuzzles()
|
||||||
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
|
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
|
||||||
|
|
||||||
// Load existing submissions using store
|
// Load existing submissions using store
|
||||||
console.log("Loading submissions...");
|
console.log('Loading submissions...')
|
||||||
await loadSubmissions();
|
await submissionsStore.loadSubmissions()
|
||||||
console.log("Submissions loaded:", submissions.value.length);
|
console.log('Submissions loaded:', submissionsStore.submissions.length)
|
||||||
|
|
||||||
console.log("Data load complete!");
|
console.log('Data load complete!')
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorHelpers.getErrorMessage(err);
|
error.value = errorHelpers.getErrorMessage(err)
|
||||||
console.error("Failed to load data:", err);
|
console.error('Failed to load data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false
|
||||||
console.log("Loading state set to false");
|
console.log('Loading state set to false')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userInfo.value?.is_superuser) {
|
if (userInfo.value.is_superuser) {
|
||||||
start();
|
start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { remaining, start } = useCountdown(60, {
|
const { remaining, start } = useCountdown(60, {
|
||||||
onComplete() {
|
onComplete() {
|
||||||
initialize();
|
initialize()
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initialize();
|
await initialize()
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const handleSubmission = async (submissionData: {
|
||||||
|
files: any[],
|
||||||
|
notes?: string,
|
||||||
|
manualValidationRequested?: boolean
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
// Create submission via store
|
||||||
|
const submission = await submissionsStore.createSubmission(
|
||||||
|
submissionData.files,
|
||||||
|
submissionData.notes,
|
||||||
|
submissionData.manualValidationRequested
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (submission) {
|
||||||
|
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
|
||||||
|
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||||
|
} else {
|
||||||
|
alert('Submission created successfully!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
submissionsStore.closeSubmissionModal()
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = errorHelpers.getErrorMessage(err)
|
||||||
|
error.value = errorMessage
|
||||||
|
alert(`Submission failed: ${errorMessage}`)
|
||||||
|
console.error('Submission error:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSubmissionModal = () => {
|
||||||
|
submissionsStore.openSubmissionModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSubmissionModal = () => {
|
||||||
|
submissionsStore.closeSubmissionModal()
|
||||||
|
}
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
// Function to match puzzle name from OCR to actual puzzle
|
||||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
const findPuzzleByName = (ocrPuzzleName: string) => {
|
||||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
|
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
|
||||||
};
|
}
|
||||||
|
|
||||||
const reloadPage = () => {
|
const reloadPage = () => {
|
||||||
window.location.reload();
|
window.location.reload()
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -118,25 +157,19 @@ const reloadPage = () => {
|
|||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
v-if="userInfo?.is_authenticated"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
v-if="userInfo.is_superuser"
|
|
||||||
class="badge badge-warning badge-xs ml-1"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">
|
||||||
<div class="flex flex-col items-end gap-2">
|
Not logged in
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
<a href="/admin" class="btn btn-xs btn-warning">
|
||||||
|
Admin django
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -154,16 +187,13 @@ const reloadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
v-if="isLoading"
|
|
||||||
class="flex justify-center items-center min-h-[400px]"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
@ -184,13 +214,14 @@ const reloadPage = () => {
|
|||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
||||||
<p class="text-base-content/70">
|
<p class="text-base-content/70">{{ props.collectionDescription }}</p>
|
||||||
{{ props.collectionDescription }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
<div class="flex flex-wrap gap-4 mt-4">
|
||||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
<button
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
@click="openSubmissionModal"
|
||||||
Submit Solution
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
|
Submit Solution
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -216,29 +247,28 @@ const reloadPage = () => {
|
|||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
<div class="text-6xl mb-4">🧩</div>
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
||||||
<p class="text-base-content/70">
|
<p class="text-base-content/70">Check back later for new puzzle collections!</p>
|
||||||
Check back later for new puzzle collections!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submission Modal -->
|
<!-- Submission Modal -->
|
||||||
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
|
||||||
<div class="modal-box max-w-6xl">
|
<div class="modal-box max-w-4xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||||
<button
|
<button
|
||||||
@click="closeSubmissionModal"
|
@click="closeSubmissionModal"
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
class="btn btn-sm btn-circle btn-ghost"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmissionForm
|
<SubmissionForm
|
||||||
:puzzles="puzzlesStore.puzzles"
|
:puzzles="puzzlesStore.puzzles"
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
:find-puzzle-by-name="findPuzzleByName"
|
||||||
|
@submit="handleSubmission"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||||
|
|||||||
@ -5,32 +5,24 @@
|
|||||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Submissions</div>
|
<div class="stat-title">Total Submissions</div>
|
||||||
<div class="stat-value text-primary">
|
<div class="stat-value text-primary">{{ stats.total_submissions }}</div>
|
||||||
{{ stats.total_submissions }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Total Responses</div>
|
<div class="stat-title">Total Responses</div>
|
||||||
<div class="stat-value text-secondary">
|
<div class="stat-value text-secondary">{{ stats.total_responses }}</div>
|
||||||
{{ stats.total_responses }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Need Validation</div>
|
<div class="stat-title">Need Validation</div>
|
||||||
<div class="stat-value text-warning">
|
<div class="stat-value text-warning">{{ stats.needs_validation }}</div>
|
||||||
{{ stats.needs_validation }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Validation Rate</div>
|
<div class="stat-title">Validation Rate</div>
|
||||||
<div class="stat-value text-success">
|
<div class="stat-value text-success">{{ Math.round(stats.validation_rate * 100) }}%</div>
|
||||||
{{ Math.round(stats.validation_rate * 100) }}%
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -38,11 +30,11 @@
|
|||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
<i class="mdi mdi-check-circle mr-1"></i>
|
||||||
Auto validation for all responses
|
Auto validation for all responses
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Responses Needing Validation -->
|
<!-- Responses Needing Validation -->
|
||||||
<div v-if="responsesNeedingValidation.length > 0">
|
<div v-if="responsesNeedingValidation.length > 0">
|
||||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
@ -54,50 +46,39 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
||||||
v-for="response in responsesNeedingValidation"
|
|
||||||
:key="response.id"
|
|
||||||
>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
<div class="font-bold">{{ response.puzzle_title }}</div>
|
||||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cost: {{ response.cost || "-" }}</span>
|
<span>Cost: {{ response.cost || '-' }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cost"
|
v-if="response.ocr_confidence_cost"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="
|
:class="getConfidenceBadgeClass(response.ocr_confidence_cost)"
|
||||||
getConfidenceBadgeClass(response.ocr_confidence_cost)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
{{ Math.round(response.ocr_confidence_cost * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Cycles: {{ response.cycles || "-" }}</span>
|
<span>Cycles: {{ response.cycles || '-' }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_cycles"
|
v-if="response.ocr_confidence_cycles"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="
|
:class="getConfidenceBadgeClass(response.ocr_confidence_cycles)"
|
||||||
getConfidenceBadgeClass(
|
|
||||||
response.ocr_confidence_cycles,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
{{ Math.round(response.ocr_confidence_cycles * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span>Area: {{ response.area || "-" }}</span>
|
<span>Area: {{ response.area || '-' }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="response.ocr_confidence_area"
|
v-if="response.ocr_confidence_area"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="
|
:class="getConfidenceBadgeClass(response.ocr_confidence_area)"
|
||||||
getConfidenceBadgeClass(response.ocr_confidence_area)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
{{ Math.round(response.ocr_confidence_area * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
@ -110,78 +91,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@click="openValidationModal(response)"
|
@click="openValidationModal(response)"
|
||||||
class="btn btn-sm btn-primary mr-2"
|
class="btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
<i class="mdi mdi-check-circle mr-1"></i>
|
||||||
Validate
|
Validate
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="response.id"
|
|
||||||
@click="autoValidation(response.id)"
|
|
||||||
class="btn btn-sm btn-warning"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-check-circle mr-1"></i>
|
|
||||||
Auto Validation
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center py-8">
|
<div v-else class="text-center py-8">
|
||||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
||||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm opacity-70">No responses currently need manual validation.</p>
|
||||||
No responses currently need manual validation.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Validation Modal -->
|
<!-- Validation Modal -->
|
||||||
<div v-if="validationModal.show" class="modal modal-open">
|
<div v-if="validationModal.show" class="modal modal-open">
|
||||||
<div class="modal-box w-11/12 max-w-5xl">
|
<div class="modal-box w-11/12 max-w-5xl">
|
||||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||||
|
|
||||||
<div v-for="file in validationModal.response?.files ?? []">
|
<div v-for="file in validationModal.response.files">
|
||||||
<img :src="file.file_url" />
|
<img :src="file.file_url">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mockup-code w-full">
|
|
||||||
<pre><code>{{ validationModal}}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="validationModal.response" class="space-y-4">
|
<div v-if="validationModal.response" class="space-y-4">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="mdi mdi-information-outline"></i>
|
<i class="mdi mdi-information-outline"></i>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">
|
<div class="font-bold">{{ validationModal.response.puzzle_title }}</div>
|
||||||
{{ validationModal.response.puzzle_name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">Review and correct the OCR data below</div>
|
<div class="text-sm">Review and correct the OCR data below</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Puzzle</span>
|
<span class="label-text">Puzzle</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="validationModal.data.puzzle"
|
v-model="validationModal.data.puzzle"
|
||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
>
|
>
|
||||||
<option value="">Select puzzle...</option>
|
<option value="">Select puzzle...</option>
|
||||||
<option
|
<option
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
v-for="puzzle in puzzlesStore.puzzles"
|
||||||
:key="puzzle.id"
|
:key="puzzle.id"
|
||||||
:value="puzzle.id"
|
:value="puzzle.id"
|
||||||
>
|
>
|
||||||
{{ puzzle.title }}
|
{{ puzzle.title }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -190,53 +156,48 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Cost</span>
|
<span class="label-text">Cost</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_cost"
|
v-model="validationModal.data.validated_cost"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
:placeholder="validationModal.response.cost || 'Enter cost'"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Cycles</span>
|
<span class="label-text">Cycles</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_cycles"
|
v-model="validationModal.data.validated_cycles"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Area</span>
|
<span class="label-text">Area</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="validationModal.data.validated_area"
|
v-model="validationModal.data.validated_area"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:placeholder="validationModal.response.area || 'Enter area'"
|
:placeholder="validationModal.response.area || 'Enter area'"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button @click="closeValidationModal" class="btn btn-ghost">
|
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
|
||||||
Cancel
|
<button
|
||||||
</button>
|
@click="submitValidation"
|
||||||
<button
|
|
||||||
@click="submitValidation"
|
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="isValidating"
|
:disabled="isValidating"
|
||||||
>
|
>
|
||||||
<span
|
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
|
||||||
v-if="isValidating"
|
{{ isValidating ? 'Validating...' : 'Validate' }}
|
||||||
class="loading loading-spinner loading-sm"
|
|
||||||
></span>
|
|
||||||
{{ isValidating ? "Validating..." : "Validate" }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -246,11 +207,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from 'vue'
|
||||||
import { apiService } from "@/services/apiService";
|
import { apiService } from '@/services/apiService'
|
||||||
import type { PuzzleResponse } from "@/types";
|
import type { PuzzleResponse } from '@/types'
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import {usePuzzlesStore} from '@/stores/puzzles'
|
||||||
const puzzlesStore = usePuzzlesStore();
|
const puzzlesStore = usePuzzlesStore()
|
||||||
|
|
||||||
// Reactive data
|
// Reactive data
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@ -258,178 +219,160 @@ const stats = ref({
|
|||||||
total_responses: 0,
|
total_responses: 0,
|
||||||
needs_validation: 0,
|
needs_validation: 0,
|
||||||
validated_submissions: 0,
|
validated_submissions: 0,
|
||||||
validation_rate: 0,
|
validation_rate: 0
|
||||||
});
|
})
|
||||||
|
|
||||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([]);
|
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false)
|
||||||
const isValidating = ref(false);
|
const isValidating = ref(false)
|
||||||
|
|
||||||
const validationModal = ref({
|
const validationModal = ref({
|
||||||
show: false,
|
show: false,
|
||||||
response: null as PuzzleResponse | null,
|
response: null as PuzzleResponse | null,
|
||||||
data: {
|
data: {
|
||||||
puzzle: -1,
|
puzzle_title: '',
|
||||||
validated_cost: "",
|
validated_cost: '',
|
||||||
validated_cycles: "",
|
validated_cycles: '',
|
||||||
validated_area: "",
|
validated_area: ''
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true
|
||||||
|
|
||||||
// Load stats (skip if endpoint doesn't exist)
|
// Load stats (skip if endpoint doesn't exist)
|
||||||
try {
|
try {
|
||||||
const statsResponse = await apiService.getStats();
|
const statsResponse = await apiService.getStats()
|
||||||
if (statsResponse.data) {
|
if (statsResponse.data) {
|
||||||
stats.value = statsResponse.data;
|
stats.value = statsResponse.data
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Stats endpoint not available:", error);
|
console.warn('Stats endpoint not available:', error)
|
||||||
// Set default stats
|
// Set default stats
|
||||||
stats.value = {
|
stats.value = {
|
||||||
total_submissions: 0,
|
total_submissions: 0,
|
||||||
total_responses: 0,
|
total_responses: 0,
|
||||||
needs_validation: 0,
|
needs_validation: 0,
|
||||||
validated_submissions: 0,
|
validated_submissions: 0,
|
||||||
validation_rate: 0,
|
validation_rate: 0
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load responses needing validation
|
// Load responses needing validation
|
||||||
const responsesResponse = await apiService.getResponsesNeedingValidation();
|
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
||||||
if (responsesResponse.data) {
|
if (responsesResponse.data) {
|
||||||
responsesNeedingValidation.value = responsesResponse.data;
|
responsesNeedingValidation.value = responsesResponse.data
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load admin data:", error);
|
console.error('Failed to load admin data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const autoValidationResponse = async () => {
|
const autoValidationResponse = async () => {
|
||||||
for (const response of Array.from(responsesNeedingValidation.value)) {
|
for (const response of Array.from(responsesNeedingValidation.value)) {
|
||||||
if (!response.id) {
|
const {data, error} = await apiService.autoValidateResponses(response.id)
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { data, error } = await apiService.autoValidateResponses(response.id);
|
|
||||||
|
|
||||||
if (data && !data.needs_manual_validation) {
|
if (data && !data.needs_manual_validation) {
|
||||||
// Remove from validation list
|
// Remove from validation list
|
||||||
responsesNeedingValidation.value =
|
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||||
responsesNeedingValidation.value.filter((r) => r.id !== response.id);
|
r => r.id !== response.id
|
||||||
stats.value.needs_validation -= 1;
|
)
|
||||||
|
stats.value.needs_validation -= 1
|
||||||
|
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const openValidationModal = (response: PuzzleResponse) => {
|
const openValidationModal = (response: PuzzleResponse) => {
|
||||||
validationModal.value.response = response;
|
validationModal.value.response = response
|
||||||
validationModal.value.data = {
|
validationModal.value.data = {
|
||||||
puzzle: response.puzzle || -1,
|
puzzle: response.puzzle || '',
|
||||||
validated_cost: response.cost || "",
|
validated_cost: response.cost || '',
|
||||||
validated_cycles: response.cycles || "",
|
validated_cycles: response.cycles || '',
|
||||||
validated_area: response.area || "",
|
validated_area: response.area || ''
|
||||||
};
|
}
|
||||||
validationModal.value.show = true;
|
validationModal.value.show = true
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeValidationModal = () => {
|
const closeValidationModal = () => {
|
||||||
validationModal.value.show = false;
|
validationModal.value.show = false
|
||||||
validationModal.value.response = null;
|
validationModal.value.response = null
|
||||||
validationModal.value.data = {
|
validationModal.value.data = {
|
||||||
puzzle: -1,
|
puzzle: '',
|
||||||
validated_cost: "",
|
validated_cost: '',
|
||||||
validated_cycles: "",
|
validated_cycles: '',
|
||||||
validated_area: "",
|
validated_area: ''
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoValidation = async (id: number) => {
|
|
||||||
const { data } = await apiService.autoValidateResponses(id);
|
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
if (data && !data.needs_manual_validation) {
|
|
||||||
// Remove from validation list
|
|
||||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
|
||||||
(r) => r.id !== id,
|
|
||||||
);
|
|
||||||
console.log(stats.value);
|
|
||||||
stats.value.needs_validation -= 1;
|
|
||||||
console.log(stats.value);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const submitValidation = async () => {
|
const submitValidation = async () => {
|
||||||
if (!validationModal.value.response?.id) return;
|
if (!validationModal.value.response?.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isValidating.value = true;
|
isValidating.value = true
|
||||||
|
|
||||||
const response = await apiService.validateResponse(
|
const response = await apiService.validateResponse(
|
||||||
validationModal.value.response.id,
|
validationModal.value.response.id,
|
||||||
validationModal.value.data,
|
validationModal.value.data
|
||||||
);
|
)
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
alert(`Validation failed: ${response.error}`);
|
alert(`Validation failed: ${response.error}`)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from validation list
|
// Remove from validation list
|
||||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||||
(r) => r.id !== validationModal.value.response?.id,
|
r => r.id !== validationModal.value.response?.id
|
||||||
);
|
)
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
stats.value.needs_validation = Math.max(
|
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
||||||
0,
|
|
||||||
stats.value.needs_validation - 1,
|
closeValidationModal()
|
||||||
);
|
|
||||||
|
|
||||||
closeValidationModal();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Validation error:", error);
|
console.error('Validation error:', error)
|
||||||
alert("Validation failed");
|
alert('Validation failed')
|
||||||
} finally {
|
} finally {
|
||||||
isValidating.value = false;
|
isValidating.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadData()
|
||||||
});
|
})
|
||||||
|
|
||||||
// Helper functions for confidence display
|
// Helper functions for confidence display
|
||||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||||
if (confidence >= 0.8) return "badge-success";
|
if (confidence >= 0.8) return 'badge-success'
|
||||||
if (confidence >= 0.6) return "badge-warning";
|
if (confidence >= 0.6) return 'badge-warning'
|
||||||
return "badge-error";
|
return 'badge-error'
|
||||||
};
|
}
|
||||||
|
|
||||||
const getOverallConfidence = (response: PuzzleResponse): number => {
|
const getOverallConfidence = (response: PuzzleResponse): number => {
|
||||||
const confidences = [
|
const confidences = [
|
||||||
response.ocr_confidence_cost,
|
response.ocr_confidence_cost,
|
||||||
response.ocr_confidence_cycles,
|
response.ocr_confidence_cycles,
|
||||||
response.ocr_confidence_area,
|
response.ocr_confidence_area
|
||||||
].filter((conf) => conf !== undefined && conf !== null) as number[];
|
].filter(conf => conf !== undefined && conf !== null) as number[]
|
||||||
|
|
||||||
if (confidences.length === 0) return 0;
|
if (confidences.length === 0) return 0
|
||||||
|
|
||||||
const average =
|
const average = confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length
|
||||||
confidences.reduce((sum, conf) => sum + conf, 0) / confidences.length;
|
return Math.round(average * 100)
|
||||||
return Math.round(average * 100);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Expose refresh method
|
// Expose refresh method
|
||||||
defineExpose({
|
defineExpose({
|
||||||
refresh: loadData,
|
refresh: loadData
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
<span class="label-text font-medium">Upload Solution Files</span>
|
<span class="label-text font-medium">Upload Solution Files</span>
|
||||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
||||||
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
@ -20,17 +20,15 @@
|
|||||||
accept="image/*,.gif"
|
accept="image/*,.gif"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
>
|
||||||
|
|
||||||
<div v-if="submissionFiles.length === 0" class="space-y-4">
|
<div v-if="files.length === 0" class="space-y-4">
|
||||||
<div
|
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
|
||||||
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
@ -42,70 +40,62 @@
|
|||||||
Supported formats: JPG, PNG, GIF (max 256MB each)
|
Supported formats: JPG, PNG, GIF (max 256MB each)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="(file, index) in submissionFiles"
|
v-for="(file, index) in files"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative group"
|
class="relative group"
|
||||||
>
|
>
|
||||||
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
||||||
<img
|
<img
|
||||||
:src="file.preview"
|
:src="file.preview"
|
||||||
:alt="file.file.name"
|
:alt="file.file.name"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
||||||
class="absolute inset-0 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
@click="removeFile(index)"
|
@click="removeFile(index)"
|
||||||
class="btn btn-error btn-lg btn-circle"
|
class="btn btn-error btn-sm btn-circle"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<p class="text-xs text-base-content/60">
|
||||||
{{ formatFileSize(file.file.size) }} •
|
{{ formatFileSize(file.file.size) }} • {{ file.type.toUpperCase() }}
|
||||||
{{ file.type.toUpperCase() }}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- OCR Status and Results -->
|
<!-- OCR Status and Results -->
|
||||||
<div
|
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
|
||||||
v-if="file.ocrProcessing"
|
|
||||||
class="mt-1 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
<span class="text-xs text-info">Extracting puzzle data...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="file.ocrError" class="mt-1">
|
<div v-else-if="file.ocrError" class="mt-1">
|
||||||
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
||||||
<div class="text-xs flex items-center justify-between">
|
<div class="text-xs flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
<span class="font-medium text-success">✓ OCR Complete</span>
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence"
|
v-if="file.ocrData.confidence"
|
||||||
class="badge badge-xs"
|
class="badge badge-xs"
|
||||||
:class="
|
:class="getConfidenceBadgeClass(file.ocrData.confidence.overall)"
|
||||||
getConfidenceBadgeClass(file.ocrData.confidence.overall)
|
|
||||||
"
|
|
||||||
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
:title="`Overall confidence: ${Math.round(file.ocrData.confidence.overall * 100)}%`"
|
||||||
>
|
>
|
||||||
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
{{ Math.round(file.ocrData.confidence.overall * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="processOCR(file)"
|
@click="retryOCR(file)"
|
||||||
class="btn btn-xs btn-ghost"
|
class="btn btn-xs btn-ghost"
|
||||||
title="Retry OCR"
|
title="Retry OCR"
|
||||||
>
|
>
|
||||||
@ -115,8 +105,8 @@
|
|||||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
||||||
<div v-if="file.ocrData.puzzle">
|
<div v-if="file.ocrData.puzzle">
|
||||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.puzzle"
|
v-if="file.ocrData.confidence?.puzzle"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
:title="`Puzzle confidence: ${Math.round(file.ocrData.confidence.puzzle * 100)}%`"
|
||||||
>
|
>
|
||||||
@ -125,8 +115,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.cost">
|
<div v-if="file.ocrData.cost">
|
||||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.cost"
|
v-if="file.ocrData.confidence?.cost"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
:title="`Cost confidence: ${Math.round(file.ocrData.confidence.cost * 100)}%`"
|
||||||
>
|
>
|
||||||
@ -135,8 +125,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.cycles">
|
<div v-if="file.ocrData.cycles">
|
||||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.cycles"
|
v-if="file.ocrData.confidence?.cycles"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
:title="`Cycles confidence: ${Math.round(file.ocrData.confidence.cycles * 100)}%`"
|
||||||
>
|
>
|
||||||
@ -145,8 +135,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="file.ocrData.area">
|
<div v-if="file.ocrData.area">
|
||||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||||
<span
|
<span
|
||||||
v-if="file.ocrData.confidence?.area"
|
v-if="file.ocrData.confidence?.area"
|
||||||
class="ml-2 opacity-60"
|
class="ml-2 opacity-60"
|
||||||
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
:title="`Area confidence: ${Math.round(file.ocrData.confidence.area * 100)}%`"
|
||||||
>
|
>
|
||||||
@ -155,28 +145,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
|
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
|
||||||
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
|
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
|
||||||
<div class="alert alert-warning alert-sm">
|
<div class="alert alert-warning alert-sm">
|
||||||
<i class="mdi mdi-alert-circle text-lg"></i>
|
<i class="mdi mdi-alert-circle text-lg"></i>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">Low OCR Confidence</div>
|
<div class="font-medium">Low OCR Confidence</div>
|
||||||
<div class="text-xs">
|
<div class="text-xs">Please select the correct puzzle manually</div>
|
||||||
Please select the correct puzzle manually
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<select
|
<select
|
||||||
v-model="file.manualPuzzleSelection"
|
v-model="file.manualPuzzleSelection"
|
||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
@change="onManualPuzzleSelection(file)"
|
@change="onManualPuzzleSelection(file)"
|
||||||
>
|
>
|
||||||
<option value="">Select puzzle...</option>
|
<option value="">Select puzzle...</option>
|
||||||
<option
|
<option
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
v-for="puzzle in puzzlesStore.puzzles"
|
||||||
:key="puzzle.id"
|
:key="puzzle.id"
|
||||||
:value="puzzle.title"
|
:value="puzzle.title"
|
||||||
>
|
>
|
||||||
{{ puzzle.title }}
|
{{ puzzle.title }}
|
||||||
@ -184,16 +172,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual OCR trigger for non-auto detected files -->
|
<!-- Manual OCR trigger for non-auto detected files -->
|
||||||
<div
|
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
||||||
v-else-if="
|
<button
|
||||||
!file.ocrProcessing && !file.ocrError && !file.ocrData
|
@click="processOCR(file)"
|
||||||
"
|
|
||||||
class="mt-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
@click="processOCR(file)"
|
|
||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-text-recognition"></i>
|
<i class="mdi mdi-text-recognition"></i>
|
||||||
@ -203,9 +186,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
@ -215,7 +198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="label">
|
<div v-if="error" class="label">
|
||||||
<span class="label-text-alt text-error">{{ error }}</span>
|
<span class="label-text-alt text-error">{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -223,146 +206,199 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from "vue";
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { ocrService } from "@/services/ocrService";
|
import { ocrService } from '@/services/ocrService'
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||||
import { useUploadsStore } from "@/stores/uploads";
|
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
||||||
import type { SubmissionFile } from "@/types";
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: SubmissionFile[]
|
||||||
|
puzzles?: SteamCollectionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
'update:modelValue': [files: SubmissionFile[]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// Pinia store
|
// Pinia store
|
||||||
const puzzlesStore = usePuzzlesStore();
|
const puzzlesStore = usePuzzlesStore()
|
||||||
const { submissionFiles, processOCR } = useUploadsStore();
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement>();
|
const fileInput = ref<HTMLInputElement>()
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false)
|
||||||
const error = ref("");
|
const error = ref('')
|
||||||
|
const files = ref<SubmissionFile[]>([])
|
||||||
|
|
||||||
|
// Watch for external changes to modelValue
|
||||||
|
watch(() => props.modelValue, (newFiles) => {
|
||||||
|
files.value = newFiles
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Watch for internal changes and emit
|
||||||
|
watch(files, (newFiles) => {
|
||||||
|
emit('update:modelValue', newFiles)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// Watch for puzzle changes and update OCR service
|
// Watch for puzzle changes and update OCR service
|
||||||
watch(
|
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
|
||||||
() => puzzlesStore.puzzles,
|
if (newPuzzles && newPuzzles.length > 0) {
|
||||||
(newPuzzles) => {
|
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
|
||||||
if (newPuzzles && newPuzzles.length > 0) {
|
}
|
||||||
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames);
|
}, { immediate: true })
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileSelect = (event: Event) => {
|
const handleFileSelect = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement
|
||||||
if (target.files) {
|
if (target.files) {
|
||||||
processFiles(Array.from(target.files));
|
processFiles(Array.from(target.files))
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent) => {
|
const handleDrop = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
isDragOver.value = false;
|
isDragOver.value = false
|
||||||
|
|
||||||
if (event.dataTransfer?.files) {
|
if (event.dataTransfer?.files) {
|
||||||
processFiles(Array.from(event.dataTransfer.files));
|
processFiles(Array.from(event.dataTransfer.files))
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const processFiles = async (newFiles: File[]) => {
|
const processFiles = async (newFiles: File[]) => {
|
||||||
error.value = "";
|
error.value = ''
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (!isValidFile(file)) {
|
if (!isValidFile(file)) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preview = await createPreview(file);
|
const preview = await createPreview(file)
|
||||||
const fileType = file.type.startsWith("image/gif") ? "gif" : "image";
|
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
||||||
|
|
||||||
const submissionFile: SubmissionFile = {
|
const submissionFile: SubmissionFile = {
|
||||||
file,
|
file,
|
||||||
file_url: "",
|
|
||||||
preview,
|
preview,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
ocrProcessing: false,
|
ocrProcessing: false,
|
||||||
ocrError: undefined,
|
ocrError: undefined,
|
||||||
ocrData: undefined,
|
ocrData: undefined
|
||||||
};
|
}
|
||||||
|
|
||||||
submissionFiles.push(submissionFile);
|
files.value.push(submissionFile)
|
||||||
|
|
||||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
||||||
if (isOpusMagnumImage(file)) {
|
if (isOpusMagnumImage(file)) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
processOCR(submissionFile);
|
processOCR(submissionFile)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = `Failed to process ${file.name}`;
|
error.value = `Failed to process ${file.name}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const isValidFile = (file: File): boolean => {
|
const isValidFile = (file: File): boolean => {
|
||||||
// Check file type
|
// Check file type
|
||||||
if (!file.type.startsWith("image/")) {
|
if (!file.type.startsWith('image/')) {
|
||||||
error.value = `${file.name} is not a valid image file`;
|
error.value = `${file.name} is not a valid image file`
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size (256MB limit)
|
// Check file size (256MB limit)
|
||||||
if (file.size > 256 * 1024 * 1024) {
|
if (file.size > 256 * 1024 * 1024) {
|
||||||
error.value = `${file.name} is too large (max 256MB)`;
|
error.value = `${file.name} is too large (max 256MB)`
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true
|
||||||
};
|
}
|
||||||
|
|
||||||
const createPreview = (file: File): Promise<string> => {
|
const createPreview = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => resolve(e.target?.result as string);
|
reader.onload = (e) => resolve(e.target?.result as string)
|
||||||
reader.onerror = reject;
|
reader.onerror = reject
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file)
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const removeFile = (index: number) => {
|
||||||
submissionFiles.splice(index, 1);
|
files.value.splice(index, 1)
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
};
|
}
|
||||||
|
|
||||||
const isOpusMagnumImage = (file: File): boolean => {
|
const isOpusMagnumImage = (file: File): boolean => {
|
||||||
// Basic heuristic - could be enhanced with actual image analysis
|
// Basic heuristic - could be enhanced with actual image analysis
|
||||||
return file.type.startsWith("image/") && file.size > 50000; // > 50KB likely screenshot
|
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const processOCR = async (submissionFile: SubmissionFile) => {
|
||||||
|
// Find the file in the reactive array to ensure proper reactivity
|
||||||
|
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
||||||
|
if (fileIndex === -1) return
|
||||||
|
|
||||||
|
// Update the reactive array directly
|
||||||
|
files.value[fileIndex].ocrProcessing = true
|
||||||
|
files.value[fileIndex].ocrError = undefined
|
||||||
|
files.value[fileIndex].ocrData = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting OCR processing for:', submissionFile.file.name)
|
||||||
|
await ocrService.initialize()
|
||||||
|
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
|
||||||
|
console.log('OCR completed:', ocrData)
|
||||||
|
|
||||||
|
// Force reactivity update
|
||||||
|
await nextTick()
|
||||||
|
files.value[fileIndex].ocrData = ocrData
|
||||||
|
|
||||||
|
// Check if puzzle confidence is below 80% and needs manual selection
|
||||||
|
if (ocrData.confidence.puzzle < 0.8) {
|
||||||
|
files.value[fileIndex].needsManualPuzzleSelection = true
|
||||||
|
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
|
||||||
|
} else {
|
||||||
|
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OCR processing failed:', error)
|
||||||
|
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
|
||||||
|
} finally {
|
||||||
|
files.value[fileIndex].ocrProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryOCR = (submissionFile: SubmissionFile) => {
|
||||||
|
processOCR(submissionFile)
|
||||||
|
}
|
||||||
|
|
||||||
const getConfidenceBadgeClass = (confidence: number): string => {
|
const getConfidenceBadgeClass = (confidence: number): string => {
|
||||||
if (confidence >= 0.8) return "badge-success";
|
if (confidence >= 0.8) return 'badge-success'
|
||||||
if (confidence >= 0.6) return "badge-warning";
|
if (confidence >= 0.6) return 'badge-warning'
|
||||||
return "badge-error";
|
return 'badge-error'
|
||||||
};
|
}
|
||||||
|
|
||||||
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
||||||
// Find the file in the reactive array
|
// Find the file in the reactive array
|
||||||
const fileIndex = submissionFiles.findIndex(
|
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
||||||
(f) => f.file === submissionFile.file,
|
if (fileIndex === -1) return
|
||||||
);
|
|
||||||
if (fileIndex === -1) return;
|
|
||||||
|
|
||||||
// Clear the manual selection requirement once user has selected
|
// Clear the manual selection requirement once user has selected
|
||||||
if (submissionFiles[fileIndex].manualPuzzleSelection) {
|
if (files.value[fileIndex].manualPuzzleSelection) {
|
||||||
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
|
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||||
console.log(
|
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
|
||||||
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,54 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
|
||||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
||||||
<p class="text-sm text-base-content/70 mb-2">
|
<p class="text-sm text-base-content/70 mb-2">by {{ puzzle.author_name }}</p>
|
||||||
by {{ puzzle.author_name }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<div class="badge badge-primary badge-sm">
|
<div class="badge badge-primary badge-sm">{{ puzzle.steam_item_id }}</div>
|
||||||
{{ puzzle.steam_item_id }}
|
<div class="badge badge-ghost badge-sm">Order: {{ puzzle.order_index + 1 }}</div>
|
||||||
</div>
|
|
||||||
<div class="badge badge-ghost badge-sm">
|
|
||||||
Order: {{ puzzle.order_index + 1 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4 line-clamp-2">
|
||||||
v-if="puzzle.description"
|
|
||||||
class="text-sm text-base-content/80 mb-4"
|
|
||||||
>
|
|
||||||
{{ puzzle.description }}
|
{{ puzzle.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
|
||||||
v-if="puzzle.tags && puzzle.tags.length > 0"
|
<span
|
||||||
class="flex flex-wrap gap-1 mb-4"
|
v-for="tag in puzzle.tags.slice(0, 3)"
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="tag in puzzle.tags.slice(0, 3)"
|
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="badge badge-outline badge-xs"
|
class="badge badge-outline badge-xs"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
|
||||||
v-if="puzzle.tags.length > 3"
|
|
||||||
class="badge badge-outline badge-xs"
|
|
||||||
>
|
|
||||||
+{{ puzzle.tags.length - 3 }} more
|
+{{ puzzle.tags.length - 3 }} more
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
<div class="tooltip" data-tip="View on Steam Workshop">
|
||||||
<a
|
<a
|
||||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="btn btn-ghost btn-sm btn-square"
|
class="btn btn-ghost btn-sm btn-square"
|
||||||
@ -58,15 +41,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responses Table -->
|
<!-- Responses Table -->
|
||||||
<div v-if="responses && responses.length > 0" class="mt-6">
|
<div v-if="responses && responses.length > 0" class="mt-6">
|
||||||
<div class="divider">
|
<div class="divider">
|
||||||
<span class="text-sm font-medium"
|
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
|
||||||
>Solutions ({{ responses.length }})</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<table class="table table-xs">
|
<table class="table table-xs">
|
||||||
<thead>
|
<thead>
|
||||||
@ -78,59 +59,32 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="response in responses" :key="response.id" class="hover">
|
||||||
v-for="response in responses"
|
|
||||||
:key="response.id"
|
|
||||||
class="hover"
|
|
||||||
>
|
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
|
||||||
v-if="response.final_cost || response.cost"
|
|
||||||
class="badge badge-success badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cost || response.cost }}
|
{{ response.final_cost || response.cost }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
|
||||||
v-if="response.final_cycles || response.cycles"
|
|
||||||
class="badge badge-info badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cycles || response.cycles }}
|
{{ response.final_cycles || response.cycles }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
|
||||||
v-if="response.final_area || response.area"
|
|
||||||
class="badge badge-warning badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_area || response.area }}
|
{{ response.final_area || response.area }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-base-content/50">-</span>
|
<span v-else class="text-base-content/50">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="badge badge-ghost badge-xs">{{
|
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
|
||||||
response.files?.length || 0
|
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')">
|
||||||
}}</span>
|
|
||||||
<div
|
|
||||||
v-if="response.files?.length"
|
|
||||||
class="tooltip"
|
|
||||||
:data-tip="
|
|
||||||
response.files
|
|
||||||
.map((f) => f.original_filename || f.file?.name)
|
|
||||||
.join(', ')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-information-outline text-xs"></i>
|
<i class="mdi mdi-information-outline text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
|
||||||
v-if="response.needs_manual_validation"
|
|
||||||
class="tooltip"
|
|
||||||
data-tip="Needs manual validation"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -140,33 +94,35 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No responses state -->
|
<!-- No responses state -->
|
||||||
<div
|
<div v-else class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg">
|
||||||
v-else
|
|
||||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
|
||||||
@click="openSubmissionModal"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
||||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
||||||
<p class="text-xs text-base-content/40">
|
<p class="text-xs text-base-content/40">Upload solutions using the submit button</p>
|
||||||
Upload solutions using the submit button
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
import type { SteamCollectionItem, PuzzleResponse } from '@/types'
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
puzzle: SteamCollectionItem;
|
puzzle: SteamCollectionItem
|
||||||
responses?: PuzzleResponse[];
|
responses?: PuzzleResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>()
|
||||||
|
|
||||||
const { openSubmissionModal } = useSubmissionsStore();
|
// Utility functions removed - not used in template
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -5,60 +5,37 @@
|
|||||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
||||||
Submit Solution
|
Submit Solution
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
<!-- Detected Puzzles Summary -->
|
<!-- Detected Puzzles Summary -->
|
||||||
<div
|
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
|
||||||
v-if="Object.keys(responsesByPuzzle).length > 0"
|
<i class="mdi mdi-information-outline text-xl"></i>
|
||||||
class="alert alert-info"
|
<div class="flex-1">
|
||||||
>
|
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
|
||||||
<i class="mdi mdi-information-outline text-xl"></i>
|
<div class="text-sm space-y-1 mt-1">
|
||||||
<div class="flex-1">
|
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
|
||||||
<h4 class="font-bold">
|
<span>{{ puzzleName }}</span>
|
||||||
Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})
|
<span class="badge badge-ghost badge-sm ml-2">{{ data.files.length }} file(s)</span>
|
||||||
</h4>
|
</div>
|
||||||
<div class="text-sm space-y-1 mt-1">
|
</div>
|
||||||
<div
|
</div>
|
||||||
v-for="(data, puzzleName) in responsesByPuzzle"
|
</div>
|
||||||
:key="puzzleName"
|
|
||||||
class="flex justify-between"
|
<!-- File Upload -->
|
||||||
>
|
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||||
<span>{{ puzzleName }}</span>
|
|
||||||
<span class="badge badge-ghost badge-sm ml-2"
|
<!-- Manual Selection Warning -->
|
||||||
>{{ data.files.length }} file(s)</span
|
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
|
||||||
>
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
</div>
|
<div class="flex-1">
|
||||||
</div>
|
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||||
</div>
|
<div class="text-sm">
|
||||||
</div>
|
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
||||||
|
Please select the correct puzzle for each file before submitting.
|
||||||
<!-- File Upload -->
|
</div>
|
||||||
<FileUpload />
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Manual Selection Warning -->
|
|
||||||
<div
|
|
||||||
v-if="submissionFilesNeedingManualSelection.length > 0"
|
|
||||||
class="alert alert-warning"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ submissionFilesNeedingManualSelection.length }} file(s) have
|
|
||||||
low OCR confidence for puzzle names. Please select the correct
|
|
||||||
puzzle for each file before submitting.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn mt-3 w-full"
|
|
||||||
@click="processLowConfidenceOCRFiles"
|
|
||||||
>
|
|
||||||
<span class="mdi mdi-reload text-2xl"></span>
|
|
||||||
Retry OCR on low confidence puzzle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@ -66,7 +43,7 @@
|
|||||||
<span class="label-text font-medium">Notes (Optional)</span>
|
<span class="label-text font-medium">Notes (Optional)</span>
|
||||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="notes"
|
v-model="notes"
|
||||||
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||||
@ -74,44 +51,37 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual Validation Request -->
|
<!-- Manual Validation Request -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="manualValidationRequested"
|
v-model="manualValidationRequested"
|
||||||
class="checkbox checkbox-primary"
|
class="checkbox checkbox-primary"
|
||||||
:disabled="hasLowConfidence"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-medium">Request manual validation</span>
|
||||||
>Request manual validation</span
|
|
||||||
>
|
|
||||||
<div class="label-text-alt text-xs opacity-70 mt-1">
|
<div class="label-text-alt text-xs opacity-70 mt-1">
|
||||||
Check this if you want an admin to manually review your
|
Check this if you want an admin to manually review your submission, even if OCR confidence is high.
|
||||||
submission, even if OCR confidence is high.
|
<br>
|
||||||
<br />
|
<em>Note: This will be automatically checked if any OCR confidence is below 50%.</em>
|
||||||
<em
|
|
||||||
>Note: This will be automatically checked if any OCR
|
|
||||||
confidence is below 80%.</em
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<div class="card-actions justify-end">
|
<div class="card-actions justify-end">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="!canSubmit">
|
<button
|
||||||
<span
|
type="submit"
|
||||||
v-if="isSubmitting"
|
class="btn btn-primary"
|
||||||
class="loading loading-spinner loading-sm"
|
:disabled="!canSubmit"
|
||||||
></span>
|
>
|
||||||
|
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||||
<span v-if="isSubmitting">Submitting...</span>
|
<span v-if="isSubmitting">Submitting...</span>
|
||||||
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
|
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||||
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
|
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
||||||
remaining)
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Submit Solution</span>
|
<span v-else>Submit Solution</span>
|
||||||
</button>
|
</button>
|
||||||
@ -122,97 +92,104 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from 'vue'
|
||||||
import FileUpload from "@/components/FileUpload.vue";
|
import FileUpload from '@/components/FileUpload.vue'
|
||||||
import type { SteamCollectionItem, SubmissionFile } from "@/types";
|
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||||
import { useUploadsStore } from "@/stores/uploads";
|
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
puzzles: SteamCollectionItem[];
|
puzzles: SteamCollectionItem[]
|
||||||
findPuzzleByName: (name: string) => SteamCollectionItem | null;
|
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
interface Emits {
|
||||||
|
submit: [submissionData: { files: SubmissionFile[], notes?: string, manualValidationRequested?: boolean }]
|
||||||
|
}
|
||||||
|
|
||||||
const uploadsStore = useUploadsStore();
|
const props = defineProps<Props>()
|
||||||
const {
|
const emit = defineEmits<Emits>()
|
||||||
submissionFiles,
|
|
||||||
hasLowConfidence,
|
|
||||||
submissionFilesNeedingManualSelection,
|
|
||||||
} = storeToRefs(uploadsStore);
|
|
||||||
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
|
|
||||||
const { handleSubmission } = useSubmissionsStore();
|
|
||||||
|
|
||||||
const notes = ref("");
|
const submissionFiles = ref<SubmissionFile[]>([])
|
||||||
const manualValidationRequested = ref(false);
|
const notes = ref('')
|
||||||
const isSubmitting = ref(false);
|
const manualValidationRequested = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
const notesLength = computed(() => notes.value.length);
|
const notesLength = computed(() => notes.value.length)
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
const hasFiles = submissionFiles.value.length > 0;
|
const hasFiles = submissionFiles.value.length > 0
|
||||||
const noManualSelectionNeeded = !submissionFiles.value.some(
|
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
||||||
(file) => file.needsManualPuzzleSelection,
|
|
||||||
);
|
return hasFiles &&
|
||||||
|
!isSubmitting.value &&
|
||||||
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
|
noManualSelectionNeeded
|
||||||
});
|
})
|
||||||
|
|
||||||
watch(hasLowConfidence, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
manualValidationRequested.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group files by detected puzzle
|
// Group files by detected puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<
|
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||||
string,
|
|
||||||
{ puzzle: SteamCollectionItem | null; files: SubmissionFile[] }
|
submissionFiles.value.forEach(file => {
|
||||||
> = {};
|
|
||||||
|
|
||||||
submissionFiles.value.forEach((file) => {
|
|
||||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle;
|
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||||
|
|
||||||
if (puzzleName) {
|
if (puzzleName) {
|
||||||
if (!grouped[puzzleName]) {
|
if (!grouped[puzzleName]) {
|
||||||
grouped[puzzleName] = {
|
grouped[puzzleName] = {
|
||||||
puzzle: props.findPuzzleByName(puzzleName),
|
puzzle: props.findPuzzleByName(puzzleName),
|
||||||
files: [],
|
files: []
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
grouped[puzzleName].files.push(file);
|
grouped[puzzleName].files.push(file)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
return grouped;
|
// Count files that need manual puzzle selection
|
||||||
});
|
const filesNeedingManualSelection = computed(() => {
|
||||||
|
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if any OCR confidence is below 50%
|
||||||
|
const hasLowConfidence = computed(() => {
|
||||||
|
return submissionFiles.value.some(file => {
|
||||||
|
if (!file.ocrData?.confidence) return false
|
||||||
|
return file.ocrData.confidence.cost < 0.5 ||
|
||||||
|
file.ocrData.confidence.cycles < 0.5 ||
|
||||||
|
file.ocrData.confidence.area < 0.5
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-check manual validation when confidence is low
|
||||||
|
watch(hasLowConfidence, (newValue) => {
|
||||||
|
if (newValue && !manualValidationRequested.value) {
|
||||||
|
manualValidationRequested.value = true
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!canSubmit.value) return;
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Emit the files and notes for the store to handle API submission
|
// Emit the files and notes for the parent to handle API submission
|
||||||
handleSubmission({
|
emit('submit', {
|
||||||
files: submissionFiles.value,
|
files: submissionFiles.value,
|
||||||
notes: notes.value.trim() || undefined,
|
notes: notes.value.trim() || undefined,
|
||||||
manualValidationRequested:
|
manualValidationRequested: manualValidationRequested.value
|
||||||
hasLowConfidence.value || manualValidationRequested.value,
|
})
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
clearFiles();
|
submissionFiles.value = []
|
||||||
notes.value = "";
|
notes.value = ''
|
||||||
manualValidationRequested.value = false;
|
manualValidationRequested.value = false
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Submission error:", error);
|
console.error('Submission error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export class OpusMagnumOCRService {
|
|||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.worker) return;
|
if (this.worker) return;
|
||||||
|
|
||||||
this.worker = await createWorker('eng');
|
this.worker = await createWorker('eng');
|
||||||
await this.worker.setParameters({
|
await this.worker.setParameters({
|
||||||
tessedit_ocr_engine_mode: '3',
|
tessedit_ocr_engine_mode: '3',
|
||||||
@ -62,29 +62,29 @@ export class OpusMagnumOCRService {
|
|||||||
await this.worker.setParameters({
|
await this.worker.setParameters({
|
||||||
// Disable all system dictionaries to prevent interference
|
// Disable all system dictionaries to prevent interference
|
||||||
load_system_dawg: '0',
|
load_system_dawg: '0',
|
||||||
load_freq_dawg: '0',
|
load_freq_dawg: '0',
|
||||||
load_punc_dawg: '0',
|
load_punc_dawg: '0',
|
||||||
load_number_dawg: '0',
|
load_number_dawg: '0',
|
||||||
load_unambig_dawg: '0',
|
load_unambig_dawg: '0',
|
||||||
load_bigram_dawg: '0',
|
load_bigram_dawg: '0',
|
||||||
load_fixed_length_dawgs: '0',
|
load_fixed_length_dawgs: '0',
|
||||||
|
|
||||||
// Use only characters from our puzzle names
|
// Use only characters from our puzzle names
|
||||||
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
|
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
|
||||||
|
|
||||||
// Optimize for single words/short phrases
|
// Optimize for single words/short phrases
|
||||||
tessedit_pageseg_mode: 8 as any, // Single word
|
tessedit_pageseg_mode: 8 as any, // Single word
|
||||||
|
|
||||||
// Increase penalties for non-dictionary words
|
// Increase penalties for non-dictionary words
|
||||||
segment_penalty_dict_nonword: '2.0',
|
segment_penalty_dict_nonword: '2.0',
|
||||||
segment_penalty_dict_frequent_word: '0.001',
|
segment_penalty_dict_frequent_word: '0.001',
|
||||||
segment_penalty_dict_case_ok: '0.001',
|
segment_penalty_dict_case_ok: '0.001',
|
||||||
segment_penalty_dict_case_bad: '0.1',
|
segment_penalty_dict_case_bad: '0.1',
|
||||||
|
|
||||||
// Make OCR more conservative about character recognition
|
// Make OCR more conservative about character recognition
|
||||||
classify_enable_learning: '0',
|
classify_enable_learning: '0',
|
||||||
classify_enable_adaptive_matcher: '1',
|
classify_enable_adaptive_matcher: '1',
|
||||||
|
|
||||||
// Preserve word boundaries
|
// Preserve word boundaries
|
||||||
preserve_interword_spaces: '1'
|
preserve_interword_spaces: '1'
|
||||||
});
|
});
|
||||||
@ -120,13 +120,13 @@ export class OpusMagnumOCRService {
|
|||||||
// Convert file to image element for canvas processing
|
// Convert file to image element for canvas processing
|
||||||
const imageUrl = URL.createObjectURL(imageFile);
|
const imageUrl = URL.createObjectURL(imageFile);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
@ -138,10 +138,10 @@ export class OpusMagnumOCRService {
|
|||||||
for (const [key, region] of Object.entries(this.regions)) {
|
for (const [key, region] of Object.entries(this.regions)) {
|
||||||
const regionCanvas = document.createElement('canvas');
|
const regionCanvas = document.createElement('canvas');
|
||||||
const regionCtx = regionCanvas.getContext('2d')!;
|
const regionCtx = regionCanvas.getContext('2d')!;
|
||||||
|
|
||||||
regionCanvas.width = region.width;
|
regionCanvas.width = region.width;
|
||||||
regionCanvas.height = region.height;
|
regionCanvas.height = region.height;
|
||||||
|
|
||||||
// Extract region from main image
|
// Extract region from main image
|
||||||
regionCtx.drawImage(
|
regionCtx.drawImage(
|
||||||
canvas,
|
canvas,
|
||||||
@ -178,7 +178,7 @@ export class OpusMagnumOCRService {
|
|||||||
// Perform OCR on the region
|
// Perform OCR on the region
|
||||||
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
||||||
let cleanText = text.trim();
|
let cleanText = text.trim();
|
||||||
|
|
||||||
// Store the confidence score for this field
|
// Store the confidence score for this field
|
||||||
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
||||||
|
|
||||||
@ -203,7 +203,7 @@ export class OpusMagnumOCRService {
|
|||||||
} else if (key === 'puzzle') {
|
} else if (key === 'puzzle') {
|
||||||
// Post-process puzzle names with aggressive matching to force selection from available puzzles
|
// Post-process puzzle names with aggressive matching to force selection from available puzzles
|
||||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||||
|
|
||||||
// If we still don't have a match and we have available puzzles, force the best match
|
// If we still don't have a match and we have available puzzles, force the best match
|
||||||
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
|
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
|
||||||
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
|
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
|
||||||
@ -218,13 +218,13 @@ export class OpusMagnumOCRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
URL.revokeObjectURL(imageUrl);
|
URL.revokeObjectURL(imageUrl);
|
||||||
|
|
||||||
// Calculate overall confidence as the average of all field confidences
|
// Calculate overall confidence as the average of all field confidences
|
||||||
const confidenceValues = Object.values(confidenceScores);
|
const confidenceValues = Object.values(confidenceScores);
|
||||||
const overallConfidence = confidenceValues.length > 0
|
const overallConfidence = confidenceValues.length > 0
|
||||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
puzzle: results.puzzle || '',
|
puzzle: results.puzzle || '',
|
||||||
cost: results.cost || '',
|
cost: results.cost || '',
|
||||||
@ -235,7 +235,7 @@ export class OpusMagnumOCRService {
|
|||||||
cost: confidenceScores.cost || 0,
|
cost: confidenceScores.cost || 0,
|
||||||
cycles: confidenceScores.cycles || 0,
|
cycles: confidenceScores.cycles || 0,
|
||||||
area: confidenceScores.area || 0,
|
area: confidenceScores.area || 0,
|
||||||
overall: overallConfidence,
|
overall: overallConfidence
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -256,14 +256,14 @@ export class OpusMagnumOCRService {
|
|||||||
private preprocessImage(imageData: ImageData): void {
|
private preprocessImage(imageData: ImageData): void {
|
||||||
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
// Convert to grayscale
|
// Convert to grayscale
|
||||||
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
||||||
|
|
||||||
// Invert the grayscale value
|
// Invert the grayscale value
|
||||||
const inverted = 255 - gray;
|
const inverted = 255 - gray;
|
||||||
|
|
||||||
data[i] = inverted; // Red
|
data[i] = inverted; // Red
|
||||||
data[i + 1] = inverted; // Green
|
data[i + 1] = inverted; // Green
|
||||||
data[i + 2] = inverted; // Blue
|
data[i + 2] = inverted; // Blue
|
||||||
@ -276,10 +276,10 @@ export class OpusMagnumOCRService {
|
|||||||
*/
|
*/
|
||||||
private levenshteinDistance(str1: string, str2: string): number {
|
private levenshteinDistance(str1: string, str2: string): number {
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j++) {
|
for (let j = 1; j <= str2.length; j++) {
|
||||||
for (let i = 1; i <= str1.length; i++) {
|
for (let i = 1; i <= str1.length; i++) {
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||||
@ -290,7 +290,7 @@ export class OpusMagnumOCRService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
return matrix[str2.length][str1.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,7 +304,7 @@ export class OpusMagnumOCRService {
|
|||||||
|
|
||||||
const cleanedOcr = ocrText.trim();
|
const cleanedOcr = ocrText.trim();
|
||||||
if (!cleanedOcr) return '';
|
if (!cleanedOcr) return '';
|
||||||
|
|
||||||
// Strategy 1: Exact match (case insensitive)
|
// Strategy 1: Exact match (case insensitive)
|
||||||
const exactMatch = this.availablePuzzleNames.find(
|
const exactMatch = this.availablePuzzleNames.find(
|
||||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||||
@ -314,31 +314,31 @@ export class OpusMagnumOCRService {
|
|||||||
// Strategy 2: Substring match (either direction)
|
// Strategy 2: Substring match (either direction)
|
||||||
const substringMatch = this.availablePuzzleNames.find(
|
const substringMatch = this.availablePuzzleNames.find(
|
||||||
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
|
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
|
||||||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
|
cleanedOcr.toLowerCase().includes(name.toLowerCase())
|
||||||
);
|
);
|
||||||
if (substringMatch) return substringMatch;
|
if (substringMatch) return substringMatch;
|
||||||
|
|
||||||
// Strategy 3: Multiple fuzzy matching approaches
|
// Strategy 3: Multiple fuzzy matching approaches
|
||||||
let bestMatch = cleanedOcr;
|
let bestMatch = cleanedOcr;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
for (const puzzleName of this.availablePuzzleNames) {
|
||||||
const scores = [
|
const scores = [
|
||||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Use the maximum score from all algorithms
|
// Use the maximum score from all algorithms
|
||||||
const maxScore = Math.max(...scores);
|
const maxScore = Math.max(...scores);
|
||||||
|
|
||||||
// Lower threshold for better matching - force selection even with moderate confidence
|
// Lower threshold for better matching - force selection even with moderate confidence
|
||||||
if (maxScore > bestScore && maxScore > 0.4) {
|
if (maxScore > bestScore && maxScore > 0.4) {
|
||||||
bestScore = maxScore;
|
bestScore = maxScore;
|
||||||
bestMatch = puzzleName;
|
bestMatch = puzzleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: If no good match found, try character-based matching
|
// Strategy 4: If no good match found, try character-based matching
|
||||||
if (bestScore < 0.6) {
|
if (bestScore < 0.6) {
|
||||||
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
const charMatch = this.findBestCharacterMatch(cleanedOcr);
|
||||||
@ -346,7 +346,7 @@ export class OpusMagnumOCRService {
|
|||||||
bestMatch = charMatch;
|
bestMatch = charMatch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestMatch;
|
return bestMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,23 +365,23 @@ export class OpusMagnumOCRService {
|
|||||||
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
|
||||||
const s1 = str1.toLowerCase();
|
const s1 = str1.toLowerCase();
|
||||||
const s2 = str2.toLowerCase();
|
const s2 = str2.toLowerCase();
|
||||||
|
|
||||||
if (s1 === s2) return 1;
|
if (s1 === s2) return 1;
|
||||||
|
|
||||||
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
|
||||||
if (matchWindow < 0) return 0;
|
if (matchWindow < 0) return 0;
|
||||||
|
|
||||||
const s1Matches = new Array(s1.length).fill(false);
|
const s1Matches = new Array(s1.length).fill(false);
|
||||||
const s2Matches = new Array(s2.length).fill(false);
|
const s2Matches = new Array(s2.length).fill(false);
|
||||||
|
|
||||||
let matches = 0;
|
let matches = 0;
|
||||||
let transpositions = 0;
|
let transpositions = 0;
|
||||||
|
|
||||||
// Find matches
|
// Find matches
|
||||||
for (let i = 0; i < s1.length; i++) {
|
for (let i = 0; i < s1.length; i++) {
|
||||||
const start = Math.max(0, i - matchWindow);
|
const start = Math.max(0, i - matchWindow);
|
||||||
const end = Math.min(i + matchWindow + 1, s2.length);
|
const end = Math.min(i + matchWindow + 1, s2.length);
|
||||||
|
|
||||||
for (let j = start; j < end; j++) {
|
for (let j = start; j < end; j++) {
|
||||||
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
||||||
s1Matches[i] = true;
|
s1Matches[i] = true;
|
||||||
@ -390,9 +390,9 @@ export class OpusMagnumOCRService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches === 0) return 0;
|
if (matches === 0) return 0;
|
||||||
|
|
||||||
// Count transpositions
|
// Count transpositions
|
||||||
let k = 0;
|
let k = 0;
|
||||||
for (let i = 0; i < s1.length; i++) {
|
for (let i = 0; i < s1.length; i++) {
|
||||||
@ -401,16 +401,16 @@ export class OpusMagnumOCRService {
|
|||||||
if (s1[i] !== s2[k]) transpositions++;
|
if (s1[i] !== s2[k]) transpositions++;
|
||||||
k++;
|
k++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
||||||
|
|
||||||
// Jaro-Winkler bonus for common prefix
|
// Jaro-Winkler bonus for common prefix
|
||||||
let prefix = 0;
|
let prefix = 0;
|
||||||
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
|
||||||
if (s1[i] === s2[i]) prefix++;
|
if (s1[i] === s2[i]) prefix++;
|
||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return jaro + (0.1 * prefix * (1 - jaro));
|
return jaro + (0.1 * prefix * (1 - jaro));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -420,24 +420,24 @@ export class OpusMagnumOCRService {
|
|||||||
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
|
||||||
const s1 = str1.toLowerCase();
|
const s1 = str1.toLowerCase();
|
||||||
const s2 = str2.toLowerCase();
|
const s2 = str2.toLowerCase();
|
||||||
|
|
||||||
if (s1 === s2) return 1;
|
if (s1 === s2) return 1;
|
||||||
if (s1.length < n || s2.length < n) return 0;
|
if (s1.length < n || s2.length < n) return 0;
|
||||||
|
|
||||||
const ngrams1 = new Set<string>();
|
const ngrams1 = new Set<string>();
|
||||||
const ngrams2 = new Set<string>();
|
const ngrams2 = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i <= s1.length - n; i++) {
|
for (let i = 0; i <= s1.length - n; i++) {
|
||||||
ngrams1.add(s1.substr(i, n));
|
ngrams1.add(s1.substr(i, n));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i <= s2.length - n; i++) {
|
for (let i = 0; i <= s2.length - n; i++) {
|
||||||
ngrams2.add(s2.substr(i, n));
|
ngrams2.add(s2.substr(i, n));
|
||||||
}
|
}
|
||||||
|
|
||||||
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
|
||||||
const union = new Set([...ngrams1, ...ngrams2]);
|
const union = new Set([...ngrams1, ...ngrams2]);
|
||||||
|
|
||||||
return intersection.size / union.size;
|
return intersection.size / union.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,7 +447,7 @@ export class OpusMagnumOCRService {
|
|||||||
private findBestCharacterMatch(ocrText: string): string | null {
|
private findBestCharacterMatch(ocrText: string): string | null {
|
||||||
let bestMatch = null;
|
let bestMatch = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
|
|
||||||
for (const puzzleName of this.availablePuzzleNames) {
|
for (const puzzleName of this.availablePuzzleNames) {
|
||||||
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
|
||||||
if (score > bestScore && score > 0.3) {
|
if (score > bestScore && score > 0.3) {
|
||||||
@ -455,7 +455,7 @@ export class OpusMagnumOCRService {
|
|||||||
bestMatch = puzzleName;
|
bestMatch = puzzleName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestMatch;
|
return bestMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,26 +465,26 @@ export class OpusMagnumOCRService {
|
|||||||
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
|
||||||
const freq1 = new Map<string, number>();
|
const freq1 = new Map<string, number>();
|
||||||
const freq2 = new Map<string, number>();
|
const freq2 = new Map<string, number>();
|
||||||
|
|
||||||
for (const char of str1) {
|
for (const char of str1) {
|
||||||
freq1.set(char, (freq1.get(char) || 0) + 1);
|
freq1.set(char, (freq1.get(char) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const char of str2) {
|
for (const char of str2) {
|
||||||
freq2.set(char, (freq2.get(char) || 0) + 1);
|
freq2.set(char, (freq2.get(char) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
|
||||||
let similarity = 0;
|
let similarity = 0;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
|
|
||||||
for (const char of allChars) {
|
for (const char of allChars) {
|
||||||
const count1 = freq1.get(char) || 0;
|
const count1 = freq1.get(char) || 0;
|
||||||
const count2 = freq2.get(char) || 0;
|
const count2 = freq2.get(char) || 0;
|
||||||
similarity += Math.min(count1, count2);
|
similarity += Math.min(count1, count2);
|
||||||
totalChars += Math.max(count1, count2);
|
totalChars += Math.max(count1, count2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalChars === 0 ? 0 : similarity / totalChars;
|
return totalChars === 0 ? 0 : similarity / totalChars;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,7 +539,7 @@ export class OpusMagnumOCRService {
|
|||||||
const len2 = str2.length;
|
const len2 = str2.length;
|
||||||
const maxLen = Math.max(len1, len2);
|
const maxLen = Math.max(len1, len2);
|
||||||
const minLen = Math.min(len1, len2);
|
const minLen = Math.min(len1, len2);
|
||||||
|
|
||||||
return maxLen === 0 ? 1 : minLen / maxLen;
|
return maxLen === 0 ? 1 : minLen / maxLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -563,11 +563,11 @@ export class OpusMagnumOCRService {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imageUrl = URL.createObjectURL(imageFile);
|
const imageUrl = URL.createObjectURL(imageFile);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
@ -575,7 +575,7 @@ export class OpusMagnumOCRService {
|
|||||||
// Draw debug rectangles
|
// Draw debug rectangles
|
||||||
ctx.strokeStyle = '#00ff00';
|
ctx.strokeStyle = '#00ff00';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
const service = new OpusMagnumOCRService();
|
const service = new OpusMagnumOCRService();
|
||||||
Object.values(service.regions).forEach(region => {
|
Object.values(service.regions).forEach(region => {
|
||||||
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { ref } from 'vue'
|
|||||||
import type { Submission, SubmissionFile } from '@/types'
|
import type { Submission, SubmissionFile } from '@/types'
|
||||||
import { submissionHelpers } from '@/services/apiService'
|
import { submissionHelpers } from '@/services/apiService'
|
||||||
import { usePuzzlesStore } from '@/stores/puzzles'
|
import { usePuzzlesStore } from '@/stores/puzzles'
|
||||||
import { errorHelpers } from "@/services/apiService";
|
|
||||||
|
|
||||||
export const useSubmissionsStore = defineStore('submissions', () => {
|
export const useSubmissionsStore = defineStore('submissions', () => {
|
||||||
// State
|
// State
|
||||||
@ -84,49 +83,6 @@ export const useSubmissionsStore = defineStore('submissions', () => {
|
|||||||
await loadSubmissions()
|
await loadSubmissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmission = async (submissionData: {
|
|
||||||
files: any[];
|
|
||||||
notes?: string;
|
|
||||||
manualValidationRequested?: boolean;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
|
|
||||||
// Create submission via store
|
|
||||||
const submission = await createSubmission(
|
|
||||||
submissionData.files,
|
|
||||||
submissionData.notes,
|
|
||||||
submissionData.manualValidationRequested,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
if (submission) {
|
|
||||||
const puzzleNames = submission.responses
|
|
||||||
.map((r) => r.puzzle_name)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
alert("Submission created successfully!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
closeSubmissionModal();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
|
|
||||||
const errorMessage = errorHelpers.getErrorMessage(err);
|
|
||||||
error.value = errorMessage;
|
|
||||||
alert(`Submission failed: ${errorMessage}`);
|
|
||||||
|
|
||||||
console.error("Submission error:", err);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
submissions,
|
submissions,
|
||||||
@ -139,7 +95,6 @@ export const useSubmissionsStore = defineStore('submissions', () => {
|
|||||||
createSubmission,
|
createSubmission,
|
||||||
openSubmissionModal,
|
openSubmissionModal,
|
||||||
closeSubmissionModal,
|
closeSubmissionModal,
|
||||||
refreshSubmissions,
|
refreshSubmissions
|
||||||
handleSubmission
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
import { SubmissionFile } from '@/types'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, nextTick, computed } from "vue";
|
|
||||||
import { ocrService } from "@/services/ocrService";
|
|
||||||
|
|
||||||
const CONFIDENCE_VALUE = 0.8;
|
|
||||||
|
|
||||||
export const useUploadsStore = defineStore('uploads', () => {
|
|
||||||
const submissionFiles = ref<SubmissionFile[]>([])
|
|
||||||
|
|
||||||
const isProcessingOCR = computed(() =>
|
|
||||||
submissionFiles.value.some(item => item.ocrProcessing)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasLowConfidence = computed(() =>
|
|
||||||
submissionFiles.value.some(file => {
|
|
||||||
return isLowConfidence(file)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const submissionFilesNeedingManualSelection = computed(() => {
|
|
||||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLowConfidence = (file: SubmissionFile) => {
|
|
||||||
if (!file.ocrData?.confidence) return false;
|
|
||||||
return (
|
|
||||||
file.ocrData.confidence.cost < CONFIDENCE_VALUE ||
|
|
||||||
file.ocrData.confidence.cycles < CONFIDENCE_VALUE ||
|
|
||||||
file.ocrData.confidence.area < CONFIDENCE_VALUE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const processOCR = async (submissionFile: SubmissionFile) => {
|
|
||||||
while (isProcessingOCR.value) {
|
|
||||||
const waitingTimeMs = Math.floor(Math.random() * 400) + 100;
|
|
||||||
console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`);
|
|
||||||
await new Promise((res) => setTimeout(res, waitingTimeMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = submissionFiles.value.indexOf(submissionFile)
|
|
||||||
|
|
||||||
// Update the reactive array directly
|
|
||||||
submissionFiles.value[index].ocrProcessing = true;
|
|
||||||
submissionFiles.value[index].ocrError = undefined;
|
|
||||||
submissionFiles.value[index].ocrData = undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Starting OCR processing for:", submissionFile.file.name);
|
|
||||||
await ocrService.initialize();
|
|
||||||
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
|
|
||||||
console.log("OCR completed:", ocrData);
|
|
||||||
|
|
||||||
// Force reactivity update
|
|
||||||
await nextTick();
|
|
||||||
submissionFiles.value[index].ocrData = ocrData;
|
|
||||||
|
|
||||||
// Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection
|
|
||||||
if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) {
|
|
||||||
submissionFiles.value[index].needsManualPuzzleSelection = true;
|
|
||||||
console.log(
|
|
||||||
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
submissionFiles.value[index].needsManualPuzzleSelection = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("OCR processing failed:", error);
|
|
||||||
submissionFiles.value[index].ocrError = "Failed to extract puzzle data";
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
submissionFiles.value[index].ocrProcessing = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processLowConfidenceOCRFiles = async () => {
|
|
||||||
const files = submissionFiles.value.filter(file => isLowConfidence(file))
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
processOCR(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearFiles = () => {
|
|
||||||
submissionFiles.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
submissionFiles,
|
|
||||||
submissionFilesNeedingManualSelection,
|
|
||||||
processOCR,
|
|
||||||
processLowConfidenceOCRFiles,
|
|
||||||
clearFiles,
|
|
||||||
|
|
||||||
// computed
|
|
||||||
isProcessingOCR,
|
|
||||||
hasLowConfidence,
|
|
||||||
|
|
||||||
CONFIDENCE_VALUE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -40,7 +40,6 @@ export interface OpusMagnumData {
|
|||||||
|
|
||||||
export interface SubmissionFile {
|
export interface SubmissionFile {
|
||||||
file: File
|
file: File
|
||||||
file_url: string
|
|
||||||
preview: string
|
preview: string
|
||||||
type: 'image' | 'gif'
|
type: 'image' | 'gif'
|
||||||
ocrData?: OpusMagnumData
|
ocrData?: OpusMagnumData
|
||||||
@ -53,8 +52,7 @@ export interface SubmissionFile {
|
|||||||
|
|
||||||
export interface PuzzleResponse {
|
export interface PuzzleResponse {
|
||||||
id?: number
|
id?: number
|
||||||
// puzzle: number | SteamCollectionItem
|
puzzle: number | SteamCollectionItem
|
||||||
puzzle: number
|
|
||||||
puzzle_name: string
|
puzzle_name: string
|
||||||
cost?: string
|
cost?: string
|
||||||
cycles?: string
|
cycles?: string
|
||||||
|
|||||||
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
21
opus_submitter/static_source/vite/assets/main-B14l8Jy0.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -16,12 +16,12 @@
|
|||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||||
},
|
},
|
||||||
"src/main.ts": {
|
"src/main.ts": {
|
||||||
"file": "assets/main-NIi3b_aN.js",
|
"file": "assets/main-B14l8Jy0.js",
|
||||||
"name": "main",
|
"name": "main",
|
||||||
"src": "src/main.ts",
|
"src": "src/main.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"css": [
|
"css": [
|
||||||
"assets/main-CYuvChoP.css"
|
"assets/main-COx9N9qO.css"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
|
|||||||
@ -33,9 +33,11 @@ def list_puzzles(request):
|
|||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
return Submission.objects.prefetch_related(
|
return (
|
||||||
"responses__files", "responses__puzzle"
|
Submission.objects.prefetch_related("responses__files", "responses__puzzle")
|
||||||
).filter(user=request.user)
|
.filter(user=request.user)
|
||||||
|
.filter()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||||
@ -68,19 +70,19 @@ def create_submission(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Check if any confidence score is below 80% to auto-request validation
|
# Check if any confidence score is below 50% to auto-request validation
|
||||||
auto_request_validation = any(
|
auto_request_validation = any(
|
||||||
(
|
(
|
||||||
response_data.ocr_confidence_cost is not None
|
response_data.ocr_confidence_cost is not None
|
||||||
and response_data.ocr_confidence_cost < 0.8
|
and response_data.ocr_confidence_cost < 0.5
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
response_data.ocr_confidence_cycles is not None
|
response_data.ocr_confidence_cycles is not None
|
||||||
and response_data.ocr_confidence_cycles < 0.8
|
and response_data.ocr_confidence_cycles < 0.5
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
response_data.ocr_confidence_area is not None
|
response_data.ocr_confidence_area is not None
|
||||||
and response_data.ocr_confidence_area < 0.8
|
and response_data.ocr_confidence_area < 0.5
|
||||||
)
|
)
|
||||||
for response_data in data.responses
|
for response_data in data.responses
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Django management command to fetch Steam Workshop collections
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from submissions.utils import create_or_update_collection
|
from submissions.utils import create_or_update_collection
|
||||||
from submissions.models import SteamAPIKey, SteamCollection
|
from submissions.models import SteamCollection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -12,6 +12,11 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
type=str,
|
||||||
|
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@ -20,23 +25,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
url = options["url"]
|
url = options["url"]
|
||||||
|
api_key = options.get("api_key")
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
|
|
||||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||||
|
|
||||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
self.stderr.write(f"No API key defined! Aborting...")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.stdout.write(f"Using api key: {api_key}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if collection already exists
|
# Check if collection already exists
|
||||||
from submissions.utils import SteamCollectionFetcher
|
from submissions.utils import SteamCollectionFetcher
|
||||||
|
|
||||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
fetcher = SteamCollectionFetcher(api_key)
|
||||||
collection_id = fetcher.extract_collection_id(url)
|
collection_id = fetcher.extract_collection_id(url)
|
||||||
|
|
||||||
if collection_id and not force:
|
if collection_id and not force:
|
||||||
|
|||||||
@ -497,15 +497,20 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
|
|||||||
)
|
)
|
||||||
|
|
||||||
valid_count = 0
|
valid_count = 0
|
||||||
for index, field in enumerate(["cost", "cycles", "area"]):
|
if r.cost == ocr_data[1]:
|
||||||
value = getattr(r, field, -1)
|
r.validated_cost = r.cost
|
||||||
|
valid_count += 1
|
||||||
|
|
||||||
if value == ocr_data[index + 1]:
|
if r.cycles == ocr_data[2]:
|
||||||
setattr(r, f"validated_{field}", value)
|
r.validated_cycles = r.cycles
|
||||||
valid_count += 1
|
valid_count += 1
|
||||||
|
|
||||||
else:
|
if r.area == ocr_data[3]:
|
||||||
setattr(r, field, ocr_data[index + 1])
|
r.validated_area = r.area
|
||||||
|
valid_count += 1
|
||||||
|
|
||||||
r.needs_manual_validation = valid_count != 3
|
if valid_count == 3:
|
||||||
r.save()
|
r.needs_manual_validation = False
|
||||||
|
|
||||||
|
if valid_count:
|
||||||
|
r.save()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -6,10 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/static/',
|
base: '/static/',
|
||||||
plugins: [
|
plugins: [vue(), tailwindcss()],
|
||||||
vue(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@ -21,5 +18,5 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: { main: resolve('./src/main.ts') }
|
input: { main: resolve('./src/main.ts') }
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,10 +7,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/static/',
|
base: '/static/',
|
||||||
plugins: [
|
plugins: [vue(), tailwindcss()],
|
||||||
vue(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@ -23,5 +20,6 @@ export default defineConfig({
|
|||||||
input:
|
input:
|
||||||
{ main: resolve('./src/main.ts') }
|
{ main: resolve('./src/main.ts') }
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,6 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions>=4.1",
|
|
||||||
"django-stubs>=5.2.7",
|
"django-stubs>=5.2.7",
|
||||||
"django-stubs-ext>=5.2.7",
|
"django-stubs-ext>=5.2.7",
|
||||||
"django-types>=0.22.0",
|
"django-types>=0.22.0",
|
||||||
|
|||||||
14
uv.lock
14
uv.lock
@ -185,18 +185,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-extensions"
|
|
||||||
version = "4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-ninja"
|
name = "django-ninja"
|
||||||
version = "1.4.5"
|
version = "1.4.5"
|
||||||
@ -516,7 +504,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "django-extensions" },
|
|
||||||
{ name = "django-stubs" },
|
{ name = "django-stubs" },
|
||||||
{ name = "django-stubs-ext" },
|
{ name = "django-stubs-ext" },
|
||||||
{ name = "django-types" },
|
{ name = "django-types" },
|
||||||
@ -543,7 +530,6 @@ requires-dist = [
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "django-extensions", specifier = ">=4.1" },
|
|
||||||
{ name = "django-stubs", specifier = ">=5.2.7" },
|
{ name = "django-stubs", specifier = ">=5.2.7" },
|
||||||
{ name = "django-stubs-ext", specifier = ">=5.2.7" },
|
{ name = "django-stubs-ext", specifier = ">=5.2.7" },
|
||||||
{ name = "django-types", specifier = ">=0.22.0" },
|
{ name = "django-types", specifier = ">=0.22.0" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user