diff --git a/.gitignore b/.gitignore index ab4bce7f..0c9043a7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ laravel/database/seeders/UserSeeder.php laravel/resources/views/emails/ laravel/.phpunit.cache .phpunit.result.cache +laravel/.phpunit.cache laravel/tests/Browser/Screenshots @@ -37,6 +38,7 @@ experiments/llm-evaluation/venv python/services/logs/ python/services/*/env/ python/services/lo_mapping_service/app/services/batch_inputs/** +python/services/lo_mapping_service/deploy/*.zip **.env python/services/lo_mapping_service/deploy/*.zip python/services/lo_mapping_service/env diff --git a/docs/Database.md b/docs/Database.md index c46d170f..d759bba3 100644 --- a/docs/Database.md +++ b/docs/Database.md @@ -36,6 +36,67 @@ and constraints. | created_at | timestamp with time zone | | | updated_at | timestamp with time zone | | + +### `course_material_chunks` table + +| Column Name | Type | Constraints | +|--------------------------|--------------------------|--------------------------------------------------------------| +| id | bigint | PK | +| course_material_file_id | bigint | FK on course_material_files, NOT NULL | +| page_number | integer | NOT NULL | +| chunk_index | integer | NOT NULL, Default: 0 | +| content | text | NOT NULL | +| content_tsv | tsvector | GENERATED ALWAYS AS (to_tsvector('english', content)) STORED | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + +**Constraints**: +* UNIQUE (course_material_file_id, page_number, chunk_index) +* GIN index on content_tsv + +### `course_material_file_topic` table + +| Column Name | Type | Constraints | +|-------------------------|--------------------------|---------------------------------------| +| course_material_file_id | bigint | FK on course_material_files, NOT NULL | +| course_topic_id | bigint | FK on course_topics, NOT NULL | + + +### `course_material_files` table + +| Column Name | Type | Constraints | +|---------------------------|--------------------------|------------------------------------------| +| course_material_file_id | bigint | PK | +| course_material_id | bigint | FK on course_materials, NOT NULL | +| uploaded_by | bigint | FK on users | +| file_name | character varying(191) | NOT NULL | +| file_path | character varying(191) | NOT NULL | +| file_size | bigint | NOT NULL | +| status | character varying(191) | NOT NULL, Default: 'PENDING' | +| error_message | text | | +| page_count | integer | | +| ocr_enabled | boolean | NOT NULL, Default: false | +| ocr_threshold | integer | NOT NULL, Default: 0 | +| extraction_engine | character varying(191) | NOT NULL, Default: 'tesseract' | +| processing_time_seconds | integer | | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + +### `course_materials` table + +| Column Name | Type | Constraints | +|--------------------|--------------------------|--------------------------| +| course_material_id | bigint | PK | +| course_id | bigint | FK on courses, NOT NULL | +| name | text | NOT NULL | +| type | character varying(191) | | +| description | text | | +| is_required | boolean | | +| url | character varying(191) | | +| position | integer | NOT NULL, Default: 0 | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + ### `course_optional_priorities` table | Column Name | Type | Constraints | @@ -55,6 +116,8 @@ and constraints. | course_required | integer | | | instructor_assigned | integer | | | map_status | integer | NOT NULL, Default: 0 | +| manual_map_status | boolean | NOT NULL, Default: false | +| ai_suggestion_status| boolean | NOT NULL, Default: false | | note | character varying(191) | | | created_at | timestamp with time zone | | | updated_at | timestamp with time zone | | @@ -83,6 +146,18 @@ and constraints. | updated_at | timestamp with time zone | | +### `course_topics` table + +| Column Name | Type | Constraints | +|-----------------|--------------------------|-------------------------| +| course_topic_id | bigint | PK | +| course_id | bigint | FK on courses, NOT NULL | +| topic | text | NOT NULL | +| description | text | | +| position | integer | NOT NULL, Default: 0 | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + ### `course_user_role` table | Column Name | Type | Constraints | @@ -424,6 +499,19 @@ and constraints. **Constraints**: * PK (l_outcome_id, a_method_id) +### `outcome_map_ai_suggestions` table + +| Column Name | Type | Constraints | +|---------------|--------------------------|-------------------------------------------| +| l_outcome_id | bigint | FK on learning_outcomes, NOT NULL | +| pl_outcome_id | bigint | FK on program_learning_outcomes, NOT NULL | +| map_scale_id | bigint | FK on mapping_scales, NOT NULL, Default: 0 | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + +**Constraints**: +* PK (l_outcome_id, pl_outcome_id, map_scale_id) + ### `outcome_maps` table | Column Name | Type | Constraints | @@ -435,7 +523,7 @@ and constraints. | updated_at | timestamp with time zone | **Constraints**: -* PK (l_outcome_id, pl_outcome_id) +* PK (l_outcome_id, pl_outcome_id, map_scale_id) ### `p_l_o_categories` table @@ -594,8 +682,24 @@ and constraints. | name | character varying(191) | NOT NULL | | description | text | | | created_at | timestamp with time zone | | -| updated_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + + +### `suggested_topics` table +| Column Name | Type | Constraints | +|-------------------------|--------------------------|--------------------------------------| +| suggested_topic_id | bigint | PK | +| course_material_file_id | bigint | FK on course_material_files, NOT NULL | +| topic | text | NOT NULL | +| score | double precision | | +| source | character varying(191) | NOT NULL, Default: 'keyword' | +| status | smallint | NOT NULL, Default: 0 | +| created_at | timestamp with time zone | | +| updated_at | timestamp with time zone | | + +**Constraints**: +* UNIQUE (course_material_file_id, topic) ### `syllabi` table diff --git a/docs/FastAPITextExtractionService.md b/docs/FastAPITextExtractionService.md new file mode 100644 index 00000000..d033ebc1 --- /dev/null +++ b/docs/FastAPITextExtractionService.md @@ -0,0 +1,160 @@ +# FastAPI Text Extraction Service + +## Overview + +The Text Extraction Service is a FastAPI application that extracts per-page text, and file-wide topics from PDF course materials. + +Text extraction is performed using a PDF parser, or OCR if required (using Tesseract or Textract). Topic extraction uses a combination of font properties and BERTopic scores to identify suggested topics. + +## Primary Responsibility + +This service is responsible for: + +- accepting text-topic extraction and topic refresh requests +- extracting text content from PDF files, page by page, and using OCR if requested +- extracting potential topics from document text by + - using BERTopic to extract keywords/phrases from the text + - using Font properties (or estimated font properties with OCR) and the material type to identify potential topics + - finding existing course topics in course text + +## API + +### `GET /health` + +Returns a simple response: + +```json +{ + "status": "ok" +} +``` + +### `POST /extract` + +The main endpoint. Accepts `multipart/form-data` with: + +- `file` (file upload stream, required): binary PDF file content +- `metadata` (JSON string, optional): JSON payload matching [`ExtractRequest`](../python/services/text_extraction_service/app/schemas.py): + - `ocr_enabled` (boolean, default `false`): whether to apply OCR on low-text pages + - `extraction_engine` (string, default `"tesseract"`): `"tesseract"` or `"textract"` + - `ocr_threshold` (integer, default `0`): page text length below which OCR is triggered (see note below) + - `material_type` (string, optional): `"slides"`, `"article"`, or `null` for default handling + - `existing_topics` (list of strings, default `[]`): course topics to match against extracted text + +Note on `ocr_threshold`: When using `tesseract`, we first count the number of non-whitespace characters on the page that are already text-readable without OCR. If the count is *greater* than `ocr_threshold`, then we simply take the already readable text as that page's text. If the count is equal to or less than `ocr_threshold`, then we convert the page to an image and perform OCR on it. If `ocr_threshold` is set to `0`, then we will only perform OCR if the page has no encoded text (for example, a scanned page). This threshold especially saves resouces when dealing with slides, as there are some pages that have figures and charts that need OCR, but others with text. + +Note (TODO): Having both `ocr_enabled` and `extraction_engine` is slightly redundant - we could have a third `extraction_engine` option be `"text-only"` or similar to cover both. However, this set up allows us to easily remove textract if we need to, as that was discussed to help simplify the set up but later retained as the service was modularized further. A potential improvement is having an `int` field with `0` correspond to text-only, and `1, 2, ...` correspond to OCR engines. + +Example request (multipart/form-data): + +Form Field `file`: `[binary stream of document.pdf]` +Form Field `metadata`: +```json +{ + "ocr_enabled": true, + "extraction_engine": "tesseract", + "ocr_threshold": 50, + "material_type": "slides", + "existing_topics": ["Climate Change", "Forest Ecology"] +} +``` + +Returns an [`ExtractResponse`](../python/services/text_extraction_service/app/schemas.py) with: + +- `pages`: list of `{ page_number, content }` for each page with extracted text +- `page_count`: total number of pages in the PDF +- `topics`: list of `{ topic, score, source }` extracted from the document + +### `POST /refresh-topics` + +Re-extracts topics from existing page content without re-running text extraction. Accepts a [`RefreshTopicsRequest`](../python/services/text_extraction_service/app/schemas.py) with: + +- `pages` (list, required): list of `{ page_number, content }` objects from a previous extraction +- `material_type` (string, optional): `"slides"`, `"article"`, or `null` +- `existing_topics` (list of strings, default `[]`): course topics to match against + +Example request: + +```json +{ + "pages": [ + { "page_number": 1, "content": "Introduction to Forest Ecology" }, + { "page_number": 2, "content": "Climate Change Impacts" } + ], + "material_type": "article", + "existing_topics": ["Climate Change", "Forest Ecology"] +} +``` + +Returns the same `ExtractResponse` format as `/extract`. + +## Text Extraction + +The extraction pipeline in [`document_extractor.py`](../python/services/text_extraction_service/app/services/text_readers/document_extractor.py) works as follows: + +1. PDF is opened with PyMuPDF +2. For each page: + - If OCR is enabled and the page has little text (below `ocr_threshold`), the page is processed with Tesseract or Textract (see **OCR Engines** below). + - Otherwise, text is extracted directly from the text layer with font size and font weight.d +3. Each page returns a list of lines with `{ text, size, bold }` metadata. The `size` and `bold` may be `None` depending on the extraction engine. + +### OCR Engines + +- **Tesseract**: Renders page to image at 300 DPI, groups words into lines, estimates font size from word-box height. Estimating whether the font is bold or not is possible but quite complex, so isn't implemented for now. +- **AWS Textract**: Single-page PDF extraction uses a synchronous call. Multi-page PDFs are uploaded to S3, processed asynchronously, and the service polls for completion. Textract returns line text only, no font metadata. However, Textract is much faster than Tesseract for large PDFs. + +## Topic Extraction Pipeline + +Topic extraction is handled by [`type_specific_handlers.py`](../python/services/text_extraction_service/app/services/topic_extraction/type_specific_handlers.py) with material-type-specific handlers. Currently, there is a specific handler for Slides, and one for Articles, as well as a default handler. + +### BERTopic Extraction + +The BERTopic extractor in [`bertopic_extractor.py`](../python/services/text_extraction_service/app/services/topic_extraction/bertopic_extractor.py): + +1. Lemmatizes text with spaCy to collapse plurals +2. Splits text into overlapping windows +3. Fits BERTopic with the `all-mpnet-base-v2` embedding model. +4. Extracts top keywords from each cluster +5. Returns the `TOPICS_COUNT` (currently 20) best-scoring topics. This limit should ideally never be reached, and is only there to prevent the database exploding with topics. We can increase the limit any time. + +## Runtime Dependencies + +Outside of what is installed via `pip`, you will need two dependencies: + +**Tesseract**: Local OCR engine. Install `tesseract-ocr` via package manager or from the [official Tesseract page](https://tesseract-ocr.github.io/tessdoc/Installation.html) and ensure `tesseract` is on your `PATH`. English language data (`eng`) must be included. + +Verify Tesseract is reachable and has English data: + +``` +tesseract --version +tesseract --list-langs # eng must appear in output +``` + +**spaCy English model**: The English NLP model used by the BERTopic extraction logic for Text lematization. After running `pip install -r requirements.txt`, run +``` +python -m spacy download en_core_web_sm +``` +Note that `_sm` stands for 'small', and is the lightest model. Heavier models like `en_core_web_lg` may yield better results, but also use more resources and space. + +## Key Environment Variables + +The environment variables are only used for Textract OCR. +If using Tesseract, none are needed. + +- `AWS_ACCESS_KEY_ID`: AWS access key for Textract (optional) +- `AWS_SECRET_ACCESS_KEY`: AWS secret key for Textract (optional) +- `AWS_REGION`: AWS region for Textract (default: `ca-central-1`) +- `AWS_S3_BUCKET`: S3 bucket for async Textract jobs (default: `text-extraction-temp`) + +## Error Handling + +Both `/extract` and `/refresh-topics` return HTTP 500 with the error message when processing fails. + +## Test Coverage +The `tests/` folder for this service contains tests for the various file handlers and postprocessor: +- **Default Material Handling** with topic matching +- **Slides Handling** with font-based extraction +- **Article Handling** with font-based extraction and soft assertion for BERTopic extraction +- **Postprocessing** with various patterns and edge cases + +AWS Textract OCR isn't covered in the tests, as it is not available in LocalStack and costs credits to use. \ No newline at end of file diff --git a/docs/HighLevelArchitecture.md b/docs/HighLevelArchitecture.md index c5c06801..9a92f7dc 100644 --- a/docs/HighLevelArchitecture.md +++ b/docs/HighLevelArchitecture.md @@ -2,11 +2,12 @@ ## Overview -This application is split into a primary Laravel web application and two Python services: +This application is split into a primary Laravel web application and a set of Python services: - `laravel/`: the main user interface of the Curriculum Mapping Tool with courses, programs, syllabi, persistence, permissions, and reporting - `python/services/syllabi_service/`: a FastAPI service that parses uploaded syllabus files and extracts structured course data - `python/services/lo_mapping_service/`: a FastAPI service that prepares and manages learning-outcome mapping via LLM (Qwen/Qwen3-8B) and post-processes the results +- `python/services/text_extraction_service/`: a FastAPI service that extracts text from PDF course materials (with Tesseract OCR if it's a scanned PDF) and also extracts likely topics based on keyword extraction and font properties ![Architecture Diagram](./images/ArchitectureDiagram.png) diff --git a/docs/Setup.md b/docs/Setup.md index 1e03b802..fee4c2d6 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -32,7 +32,7 @@ npm install cp .env.example .env ``` -The committed `.env.example` is the template every developer starts from. Its relevant database section is: +Use `.env.example` as a template for `.env`, and modify values as required. ``` DB_CONNECTION=pgsql @@ -76,13 +76,13 @@ If you see `psql: command not found`, PostgreSQL is not installed; install it fi #### Create the `root` role -`initdb` creates the `postgres` superuser but not `root`. Since `.env.example` ships with `DB_USERNAME=root`, create that role once: +Create the root role: ``` psql -U postgres -h 127.0.0.1 -c "CREATE ROLE root LOGIN PASSWORD '';" ``` -The empty password matches `DB_PASSWORD=` in the template. // TODO Check this +The above command has a blank password. If setting a password, also set it in `.env`, under `DB_PASSWORD=` #### Create the application database @@ -97,6 +97,28 @@ psql -U postgres -h 127.0.0.1 -c "CREATE DATABASE laravel OWNER root;" php artisan storage:link ``` +### Optional: PHP upload limits (required for Course Material File uploads) + +The Course Material File extraction feature lets users upload PDF course materials. PHP's default limits are small, so set these larger values in your active `php.ini`: + +```ini +upload_max_filesize = 50M +post_max_size = 60M +memory_limit = 256M +``` + +Your active `php.ini` is the file listed as **"Loaded Configuration File"** when you run + +``` +php --ini +``` + +### Optional: PDF Thumbnails for Materials Search + +Rendering thumbnails requires `pdftoppm` from `poppler-utils`. Install via package manager or from [Poppler releases page](https://poppler.freedesktop.org/) and ensure `pdftoppm` is on your `PATH`. + +Verify with `pdftoppm -v`. + ### Run migrations and seeders ``` @@ -133,6 +155,11 @@ Start the queue worker in one terminal: php artisan queue:work ``` +Note: If you need OCR, add this flag +``` +php artisan queue:work --memory=2048 +``` + Run the application in another: ``` @@ -227,6 +254,7 @@ python -m venv env ``` pip install -r requirements.txt ``` +Also check the service's documentation under `docs/` for additional specific setup steps. ### Run the Service diff --git a/laravel/.env.example b/laravel/.env.example index e201d960..6f26b748 100644 --- a/laravel/.env.example +++ b/laravel/.env.example @@ -18,6 +18,7 @@ DB_PASSWORD= BROADCAST_DRIVER=log CACHE_DRIVER=file QUEUE_CONNECTION=sync +# QUEUE_CONNECTION=database # Use database if you're using OCR SESSION_DRIVER=file SESSION_LIFETIME=120 @@ -48,3 +49,8 @@ VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" PYTHON_API_URL=http:// + +# Required on Windows only +# Required for Tesseract OCR and search PDF previews +# Should look like path\to\pdftoppm.exe +PDFTOPPM_PATH= diff --git a/laravel/app/Exceptions/Handler.php b/laravel/app/Exceptions/Handler.php index 56af2640..9c47b1e1 100644 --- a/laravel/app/Exceptions/Handler.php +++ b/laravel/app/Exceptions/Handler.php @@ -3,6 +3,7 @@ namespace App\Exceptions; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Http\Exceptions\PostTooLargeException; use Throwable; class Handler extends ExceptionHandler @@ -26,5 +27,20 @@ public function register(): void $this->reportable(function (Throwable $e) { // }); + + $this->renderable(function (PostTooLargeException $e, $request) { + $limit = ini_get('post_max_size'); + $referer = $request->headers->get('referer'); + + if ($referer) { + $separator = str_contains($referer, '?') ? '&' : '?'; + return redirect($referer . $separator . 'upload_error=too_large&limit=' . urlencode($limit)); + } + + return response( + "Upload too large. The current server limit is {$limit}B. Please go back and try a smaller file.", + 413 + ); + }); } } diff --git a/laravel/app/Http/Controllers/CourseMaterialController.php b/laravel/app/Http/Controllers/CourseMaterialController.php index 7a5a9034..08658556 100644 --- a/laravel/app/Http/Controllers/CourseMaterialController.php +++ b/laravel/app/Http/Controllers/CourseMaterialController.php @@ -3,20 +3,15 @@ namespace App\Http\Controllers; use App\Models\Course; +use App\Models\CourseMaterial; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use App\Models\CourseMaterial; use Throwable; class CourseMaterialController extends Controller { - /** - * Display a listing of the resource. - * - * @return \Illuminate\Http\Response - */ public function __construct() { $this->middleware(['auth', 'verified']); @@ -24,73 +19,49 @@ public function __construct() public function index(): RedirectResponse { - // return redirect()->back(); } - /** - * Show the form for creating a new resource. - * - * @return \Illuminate\Http\Response - */ - public function create() + public function store(Request $request): RedirectResponse { - // - } - - /** - * Store a newly created resource in storage. - * - * @return \Illuminate\Http\Response - */ - public function store(Request $request){ $request->validate([ 'course_id' => ['required', 'exists:courses,course_id'], 'current_material.*.name' => ['required', 'string'], + 'current_material.*.type' => ['required', 'string'], 'new_material.*.name' => ['required', 'string'], + 'new_material.*.type' => ['required', 'string'], ]); - //try update course material - try { $courseId = $request->input('course_id'); $currentMaterial = $request->input('current_material'); $newMaterial = $request->input('new_material'); - //get course $course = Course::find($courseId); - //case: delete all Course Topics - if(!$currentMaterial && !$newMaterial){ + if (!$currentMaterial && !$newMaterial) { $course->courseMaterials()->delete(); } - //get saved material - $courseMaterial = $course->courseMaterials; - - //update current material - foreach($courseMaterial as $material){ - if($currentMaterial && array_key_exists($material->course_material_id, $currentMaterial)){ + $courseMaterials = $course->courseMaterials; + foreach ($courseMaterials as $material) { + if ($currentMaterial && array_key_exists($material->course_material_id, $currentMaterial)) { $materialData = $currentMaterial[$material->course_material_id]; $material->name = $materialData['name']; $material->type = $materialData['type'] ?? null; $material->description = $materialData['description'] ?? null; $material->is_required = isset($materialData['is_required']); - $material->url = $materialData["url"] ?? null; - - $material->save(); + $material->url = $materialData['url'] ?? null; - } else{ - //remove material from course + $material->save(); + } else { $material->delete(); } } - //add new materials - - if($newMaterial){ + if ($newMaterial) { $materialsToInsert = []; $timestamp = now(); @@ -117,83 +88,39 @@ public function store(Request $request){ } } - // update courses 'updated_at' field - $course = Course::find($request->input('course_id')); + $course = Course::find($courseId); $course->touch(); - // get users name for last_modified_user $user = User::find(Auth::id()); $course->last_modified_user = $user->name; $course->save(); $request->session()->flash('success', 'Your course material was updated successfully!'); } catch (Throwable $exception) { - // flash error message if something goes wrong - $request->session()->flash('error', 'There was an error updating your course material'); - + $request->session()->flash('error', 'There was an error updating your course material.'); } finally { - // return back to course topics step return redirect()->route('courseWizard.step10', $request->input('course_id')); } - - } - - /** - * Display the specified resource. - * - * @return \Illuminate\Http\Response - */ - public function show(CourseMaterial $courseMaterial) - { - // - } - - /** - * Show the form for editing the specified resource. - * - * @return \Illuminate\Http\Response - */ - public function edit(CourseMaterial $courseMaterial) - { - // - } - - /** - * Update the specified resource in storage. - * - * @return \Illuminate\Http\Response - */ - public function update(Request $request, CourseMaterial $courseMaterial) - { - // } - /** - * Remove the specified resource from storage. - * - * @param \App\Models\CourseTopic $courseTopic - */ public function destroy(Request $request, $course_material_id): RedirectResponse { - //looks up material and course id, and also checks if the relationship matches $course_id = $request->input('course_id'); $courseMaterial = CourseMaterial::where('course_material_id', $course_material_id) ->where('course_id', $course_id) ->first(); if ($courseMaterial && $courseMaterial->delete()) { - // update courses 'updated_at' field $course = Course::find($course_id); $course->touch(); - // get users name for last_modified_user $user = User::find(Auth::id()); $course->last_modified_user = $user->name; $course->save(); - $request->session()->flash('success', 'Course material has been deleted'); + $request->session()->flash('success', 'Course material has been deleted.'); } else { - $request->session()->flash('error', 'There was an error deleting the course material'); + $request->session()->flash('error', 'There was an error deleting the course material.'); } return redirect()->route('courseWizard.step10', $request->input('course_id')); diff --git a/laravel/app/Http/Controllers/CourseMaterialFileController.php b/laravel/app/Http/Controllers/CourseMaterialFileController.php new file mode 100644 index 00000000..f25d1f45 --- /dev/null +++ b/laravel/app/Http/Controllers/CourseMaterialFileController.php @@ -0,0 +1,364 @@ +middleware(['auth', 'verified']); + $this->middleware('hasAccess'); + } + + public function store(Request $request, $course_id, $material_id): RedirectResponse + { + $this->verifyUserIsEditor((int) $course_id); + + $request->validate([ + 'file' => ['required', 'file', 'mimes:pdf', 'max:51200'], + 'ocr_enabled' => ['sometimes', 'boolean'], + 'extraction_engine' => ['required_if:ocr_enabled,1', 'in:tesseract,textract'], + 'ocr_threshold' => ['sometimes', 'integer', 'min:0', 'max:100000'], + ]); + + $uploaded = $request->file('file'); + $diskPath = $uploaded->storeAs( + 'course-materials/' . $course_id, + Str::uuid()->toString() . '.pdf', + 'local' + ); + + if ($diskPath === false) { + return redirect()->back()->with('error', 'Failed to store the uploaded file.'); + } + + $file = CourseMaterialFile::create([ + 'course_material_id' => $material_id, + 'uploaded_by' => Auth::id(), + 'file_name' => $uploaded->getClientOriginalName(), + 'file_path' => $diskPath, + 'file_size' => $uploaded->getSize(), + 'status' => CourseMaterialFile::STATUS_PENDING, + 'extraction_engine' => $request->input('extraction_engine', 'tesseract'), + 'ocr_enabled' => $request->boolean('ocr_enabled'), + 'ocr_threshold' => $request->boolean('ocr_enabled') ? (int) $request->input('ocr_threshold', 0) : 0, + ]); + + IndexCourseMaterial::dispatch($file->course_material_file_id); + + return redirect() + ->route('courseWizard.step10', ['course' => $course_id]) + ->with('success', 'File uploaded. Indexing in the background.'); + } + + public function destroy(Request $request, $course_id, $material_id, $file_id): RedirectResponse + { + $this->verifyUserIsEditor((int) $course_id); + + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + if (Storage::disk('local')->exists($file->file_path)) { + Storage::disk('local')->delete($file->file_path); + } + $file->delete(); + + return redirect() + ->route('courseWizard.step10', ['course' => $course_id]) + ->with('success', 'File deleted.'); + } + + public function refresh(Request $request, $course_id, $material_id, $file_id): RedirectResponse + { + $this->verifyUserIsEditor((int) $course_id); + + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + $file->update([ + 'status' => CourseMaterialFile::STATUS_PENDING, + 'error_message' => null, + 'processing_time_seconds' => null, + ]); + + $refreshingTopicsOnly = $file->chunks()->exists(); + IndexCourseMaterial::dispatch($file->course_material_file_id, $refreshingTopicsOnly); + + return redirect() + ->back() + ->with('success', $refreshingTopicsOnly ? 'Refreshing topics.' : 'Refreshing extracted text and topics.'); + } + + public function download($course_id, $material_id, $file_id): StreamedResponse + { + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + abort_unless(Storage::disk('local')->exists($file->file_path), 404); + + return Storage::disk('local')->download($file->file_path, $file->file_name); + } + + public function view($course_id, $material_id, $file_id): StreamedResponse + { + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + abort_unless(Storage::disk('local')->exists($file->file_path), 404); + + return Storage::disk('local')->response($file->file_path, $file->file_name); + } + + public function thumbnail(Request $request, $course_id, $material_id, $file_id): Response + { + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + $absolutePath = Storage::disk('local')->path($file->file_path); + abort_unless(file_exists($absolutePath), 404); + + $page = $request->validate(['page' => ['sometimes', 'integer', 'min:1', 'max:' . $file->page_count]])['page'] ?? 1; + + $pngPath = PdfPageRenderer::pdfToImage($absolutePath, $page, 96); + try { + return response(file_get_contents($pngPath), 200) + ->header('Content-Type', 'image/png'); + } finally { + @unlink($pngPath); + } + } + + public function show($course_id, $material_id, $file_id) + { + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->with([ + 'courseMaterial', + 'uploader', + 'chunks' => fn($q) => $q->orderBy('page_number')->orderBy('chunk_index'), + 'topics' => fn($q) => $q->orderBy('position'), + ]) + ->firstOrFail(); + + $courseTopics = CourseTopic::where('course_id', $course_id) + ->orderBy('position') + ->get(); + + $fileTopicTexts = $file->topics->pluck('topic')->map(fn($t) => strtolower($t))->all(); + + $suggestedTopics = $file->suggestedTopics() + ->where('status', SuggestedTopic::STATUS_PENDING) + ->orderBy('score', 'desc') + ->get() + // TODO: Should this be filtered out somewhere else instead? + ->filter(fn($s) => !in_array(strtolower($s->topic), $fileTopicTexts)); + + return view('courses.material_file', [ + 'file' => $file, + 'course_id' => $course_id, + 'material_id' => $material_id, + 'courseTopics' => $courseTopics, + 'suggestedTopics' => $suggestedTopics, + ]); + } + + public function updateTopics(Request $request, $course_id, $material_id, $file_id) + { + $this->verifyUserIsEditor((int) $course_id); + + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + $request->validate([ + 'topic_ids' => 'required|array', + 'topic_ids.*' => 'required|integer|exists:course_topics,course_topic_id', + ]); + + $topicIds = $request->input('topic_ids', []); + + // Verify all topic IDs belong to this course + $validCount = CourseTopic::where('course_id', $course_id) + ->whereIn('course_topic_id', $topicIds) + ->count(); + + if ($validCount !== count($topicIds)) { + return redirect()->back()->with('error', 'One or more selected topics do not belong to this course.'); + } + + $file->topics()->sync($topicIds); + + return redirect()->back()->with('success', 'Topics updated successfully.'); + } + + + public function reviewTopics(Request $request, $course_id, $material_id, $file_id) + { + $this->verifyUserIsEditor((int) $course_id); + + $request->validate([ + 'decisions' => 'required|array|min:1', + 'decisions.*.id' => 'required|integer|exists:suggested_topics,suggested_topic_id', + 'decisions.*.action' => 'required|in:confirm,reject', + ]); + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + + $decisions = $request->input('decisions'); + $confirmIds = array_column(array_filter($decisions, fn($d) => $d['action'] === 'confirm'), 'id'); + $rejectIds = array_column(array_filter($decisions, fn($d) => $d['action'] === 'reject'), 'id'); + + DB::transaction(function () use ($file, $confirmIds, $rejectIds) { + if ($confirmIds) { + $topicTexts = SuggestedTopic::where('course_material_file_id', $file->course_material_file_id) + ->whereIn('suggested_topic_id', $confirmIds) + ->where('status', SuggestedTopic::STATUS_PENDING) + ->pluck('topic') + ->all(); + + $this->applyTopicDecisions($file, $topicTexts); + } + + if ($rejectIds) { + SuggestedTopic::where('course_material_file_id', $file->course_material_file_id) + ->whereIn('suggested_topic_id', $rejectIds) + ->where('status', SuggestedTopic::STATUS_PENDING) + ->update(['status' => SuggestedTopic::STATUS_REJECTED]); + } + }); + + $accepted = count($confirmIds); + $rejected = count($rejectIds); + return redirect()->back()->with('success', "{$accepted} topics accepted, {$rejected} topics rejected."); + } + + public function acceptAllTopics(Request $request, $course_id, $material_id, $file_id) + { + $this->verifyUserIsEditor((int) $course_id); + + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + DB::transaction(function () use ($file) { + $topicTexts = $file->suggestedTopics() + ->where('status', SuggestedTopic::STATUS_PENDING) + ->pluck('topic') + ->all(); + + $this->applyTopicDecisions($file, $topicTexts); + }); + + return redirect()->back()->with('success', 'All suggested topics accepted.'); + } + + public function rejectAllTopics(Request $request, $course_id, $material_id, $file_id) + { + $this->verifyUserIsEditor((int) $course_id); + + $file = CourseMaterialFile::where('course_material_file_id', $file_id) + ->where('course_material_id', $material_id) + ->firstOrFail(); + + $file->suggestedTopics() + ->where('status', SuggestedTopic::STATUS_PENDING) + ->update(['status' => SuggestedTopic::STATUS_REJECTED]); + + return redirect()->back()->with('success', 'All suggested topics rejected.'); + } + + private function applyTopicDecisions(CourseMaterialFile $file, array $topicTexts): void + { + if (empty($topicTexts)) { + return; + } + + $originalByLower = []; + foreach ($topicTexts as $text) { + $key = strtolower($text); + if (!isset($originalByLower[$key])) { + $originalByLower[$key] = $text; + } + } + $lowerKeys = array_keys($originalByLower); + + $existingTopics = CourseTopic::where('course_id', $file->courseMaterial->course_id) + ->whereIn(DB::raw('LOWER(topic)'), $lowerKeys) + ->get() + ->keyBy(fn($t) => strtolower($t->topic)); + + $existingIds = []; + $newNames = []; + + foreach ($lowerKeys as $key) { + if ($existingTopics->has($key)) { + $existingIds[] = $existingTopics->get($key)->course_topic_id; + } else { + $newNames[] = $originalByLower[$key]; + } + } + + $newIds = []; + if ($newNames) { + $maxPosition = CourseTopic::where('course_id', $file->courseMaterial->course_id)->max('position') ?? 0; + + $rows = array_map( + fn($i, $name) => [ + 'course_id' => $file->courseMaterial->course_id, + 'topic' => $name, + 'position' => $maxPosition + $i + 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + range(0, count($newNames) - 1), + $newNames + ); + + DB::table('course_topics')->insert($rows); + + $newLowerNames = array_map('strtolower', $newNames); + $newIds = CourseTopic::where('course_id', $file->courseMaterial->course_id) + ->whereIn(DB::raw('LOWER(topic)'), $newLowerNames) + ->pluck('course_topic_id') + ->all(); + } + + $allTopicIds = array_unique(array_merge($existingIds, $newIds)); + + $file->topics()->syncWithoutDetaching($allTopicIds); + + SuggestedTopic::where('course_material_file_id', $file->course_material_file_id) + ->where('status', SuggestedTopic::STATUS_PENDING) + ->whereIn(DB::raw('LOWER(topic)'), $lowerKeys) + ->delete(); + } + + + private function verifyUserIsEditor(int $course_id): void + { + $permission = User::find(Auth::id())?->effectivePermissionForCourse($course_id) ?? 0; + abort_unless(in_array($permission, [1, 2], true), 403, 'Editor access required.'); + } +} diff --git a/laravel/app/Http/Controllers/CourseWizardController.php b/laravel/app/Http/Controllers/CourseWizardController.php index 08ee452a..2d6402ec 100644 --- a/laravel/app/Http/Controllers/CourseWizardController.php +++ b/laravel/app/Http/Controllers/CourseWizardController.php @@ -907,7 +907,7 @@ public function step10($course_id, Request $request) } } - $courseMaterials = $course->courseMaterials->sortBy('position')->values(); + $courseMaterials = $course->courseMaterials()->with('files')->orderBy('position')->get(); $standard_categories = DB::table('standard_categories')->get(); diff --git a/laravel/app/Http/Controllers/CoverageAnalysisController.php b/laravel/app/Http/Controllers/CoverageAnalysisController.php new file mode 100644 index 00000000..a9ef2776 --- /dev/null +++ b/laravel/app/Http/Controllers/CoverageAnalysisController.php @@ -0,0 +1,112 @@ +middleware(['auth', 'verified']); + $this->middleware('hasAccess'); + } + + public function course(Request $request, $course_id): View + { + $course = Course::where('course_id', $course_id)->firstOrFail(); + + return view('courses.coverageAnalysis', compact('course')); + } + + public function program(Request $request, $program_id): View + { + $program = Program::where('program_id', $program_id)->firstOrFail(); + + $courses = $program->courses()->orderBy('course_code')->orderBy('course_num')->get(); + + return view('programs.coverageAnalysis', compact('program', 'courses')); + } + + public function searchCourse(Request $request, $course_id): View|RedirectResponse + { + $searchTerm = trim($request->input('query', '')); + + if ($searchTerm === '') { + return redirect()->route('course.coverageAnalysis', ['course' => $course_id]); + } + + $headlineOptions = 'StartSel=' . SearchedTextHighlighter::START_MARKER + . ', StopSel=' . SearchedTextHighlighter::END_MARKER + . ', MaxFragments=2, MinWords=5, MaxWords=25'; + + $results = DB::table('course_material_chunks as c') + ->join('course_material_files as f', 'f.course_material_file_id', '=', 'c.course_material_file_id') + ->join('course_materials as m', 'm.course_material_id', '=', 'f.course_material_id') + ->where('m.course_id', $course_id) + ->whereRaw("c.content_tsv @@ plainto_tsquery('english', ?)", [$searchTerm]) + ->selectRaw(" + f.course_material_file_id as file_id, + f.course_material_id, + f.file_name, + m.course_id, + c.page_number, + ts_rank(c.content_tsv, plainto_tsquery('english', ?)) as rank, + ts_headline('english', c.content, plainto_tsquery('english', ?), ?) as snippet + ", [$searchTerm, $searchTerm, $headlineOptions]) + ->orderByDesc('rank') + ->limit(20) + ->get(); + + return redirect() + ->route('course.coverageAnalysis', ['course' => $course_id]) + ->with('search_results', $results) + ->with('search_query', $searchTerm); + } + + public function searchProgram(Request $request, $program_id): RedirectResponse + { + $searchTerm = trim($request->input('query', '')); + + if ($searchTerm === '') { + return redirect()->route('program.coverageAnalysis', ['program' => $program_id]); + } + + $headlineOptions = 'StartSel=' . SearchedTextHighlighter::START_MARKER + . ', StopSel=' . SearchedTextHighlighter::END_MARKER + . ', MaxFragments=2, MinWords=5, MaxWords=25'; + + $courseIds = DB::table('course_programs') + ->where('program_id', $program_id) + ->pluck('course_id'); + + $results = DB::table('course_material_chunks as c') + ->join('course_material_files as f', 'f.course_material_file_id', '=', 'c.course_material_file_id') + ->join('course_materials as m', 'm.course_material_id', '=', 'f.course_material_id') + ->whereIn('m.course_id', $courseIds) + ->whereRaw("c.content_tsv @@ plainto_tsquery('english', ?)", [$searchTerm]) + ->selectRaw(" + f.course_material_file_id as file_id, + f.course_material_id, + f.file_name, + m.course_id, + c.page_number, + ts_rank(c.content_tsv, plainto_tsquery('english', ?)) as rank, + ts_headline('english', c.content, plainto_tsquery('english', ?), ?) as snippet + ", [$searchTerm, $searchTerm, $headlineOptions]) + ->orderByDesc('rank') + ->limit(20) + ->get(); + + return redirect() + ->route('program.coverageAnalysis', ['program' => $program_id]) + ->with('search_results', $results) + ->with('search_query', $searchTerm); + } +} diff --git a/laravel/app/Jobs/IndexCourseMaterial.php b/laravel/app/Jobs/IndexCourseMaterial.php new file mode 100644 index 00000000..943f4810 --- /dev/null +++ b/laravel/app/Jobs/IndexCourseMaterial.php @@ -0,0 +1,176 @@ +courseMaterialFileId = $courseMaterialFileId; + $this->refreshingTopicsOnly = $refreshingTopicsOnly; + } + + public function handle(): void + { + $file = CourseMaterialFile::find($this->courseMaterialFileId); + + if (!$file) { + Log::error("IndexCourseMaterial: file {$this->courseMaterialFileId} not found, cancelling."); + return; + } + + $file->update(['status' => CourseMaterialFile::STATUS_INDEXING, 'error_message' => null]); + + try { + $startTime = microtime(true); + + $existingTopics = CourseTopic::where('course_id', $file->courseMaterial->course_id) + ->pluck('topic') + ->all(); + + if ($this->refreshingTopicsOnly) { + $pages = $file->chunks() + ->orderBy('page_number') + ->get() + ->map(fn($chunk) => ['page_number' => $chunk->page_number, 'content' => $chunk->content]) + ->all(); + + $response = Http::timeout($this->timeout) + ->post(config('services.text_extraction.base_url') . '/refresh-topics', [ + 'pages' => $pages, + 'material_type' => $file->courseMaterial?->type, + 'existing_topics' => $existingTopics, + ]); + } else { + $absolutePath = Storage::disk('local')->path($file->file_path); + $metadata = json_encode([ + 'ocr_enabled' => (bool) $file->ocr_enabled, + 'extraction_engine' => $file->extraction_engine, + 'ocr_threshold' => (int) $file->ocr_threshold, + 'material_type' => $file->courseMaterial?->type, + 'existing_topics' => $existingTopics, + ]); + + $response = Http::timeout($this->timeout) + ->attach('file', fopen($absolutePath, 'r'), basename($absolutePath)) + ->post(config('services.text_extraction.base_url') . '/extract', [ + 'metadata' => $metadata, + ]); + } + + $response->throw(); + $data = $response->json(); + + if (!$this->refreshingTopicsOnly) { + $pages = $data['pages'] ?? []; + $file->update(['page_count' => $data['page_count'] ?? count($pages)]); + + $rows = []; + foreach ($pages as $page) { + if (!empty($page['content'])) { + $rows[] = $this->pageAsDBChunk($file, $page['page_number'], $page['content']); + } + } + + $this->saveTextChunks($file, $rows); + } + + $this->saveExtractedTopics($file, $data['topics'] ?? [], $this->refreshingTopicsOnly); + + $processingTime = (int) round(microtime(true) - $startTime); + $file->update(['status' => CourseMaterialFile::STATUS_INDEXED, 'processing_time_seconds' => $processingTime]); + } catch (Throwable $exception) { + Log::error("IndexCourseMaterial failed for file {$file->course_material_file_id}: " . $exception->getMessage()); + $file->update([ + 'status' => CourseMaterialFile::STATUS_FAILED, + 'error_message' => mb_substr($exception->getMessage(), 0, 1000), + ]); + throw $exception; + } + } + + private function pageAsDBChunk(CourseMaterialFile $file, int $pageNumber, string $text): array + { + return [ + 'course_material_file_id' => $file->course_material_file_id, + 'page_number' => $pageNumber, + 'chunk_index' => 0, + 'content' => $text, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + private function saveTextChunks(CourseMaterialFile $file, array $rows): void + { + // The array can be really long for articles/textbooks, + // so insert chunks of 100 at a time + foreach (array_chunk($rows, 100) as $batch) { + CourseMaterialChunk::insert($batch); + } + } + + private function saveExtractedTopics(CourseMaterialFile $file, array $topics, bool $refreshingTopicsOnly = false): void + { + if ($refreshingTopicsOnly) { + // Only delete topics that can be re-extracted (keyword and match) + $file->suggestedTopics() + ->where('status', '!=', SuggestedTopic::STATUS_REJECTED) + ->whereIn('source', ['keyword', 'match']) + ->delete(); + } else { + // Full extraction: delete all non-rejected topics + $file->suggestedTopics() + ->where('status', '!=', SuggestedTopic::STATUS_REJECTED) + ->delete(); + } + + $existingTopics = $file->suggestedTopics() + ->pluck('topic') + ->flip(); + + $rows = []; + foreach ($topics as $topic) { + $text = trim($topic['topic'] ?? ''); + if ($text === '' || $existingTopics->has($text)) { + continue; + } + $rows[] = [ + 'course_material_file_id' => $file->course_material_file_id, + 'topic' => $text, + 'score' => $topic['score'] ?? null, + 'source' => $topic['source'] ?? 'keyword', + 'status' => SuggestedTopic::STATUS_PENDING, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + SuggestedTopic::insert($rows); + } +} diff --git a/laravel/app/Models/CourseMaterial.php b/laravel/app/Models/CourseMaterial.php index 8bd349c9..33298c2a 100644 --- a/laravel/app/Models/CourseMaterial.php +++ b/laravel/app/Models/CourseMaterial.php @@ -25,10 +25,13 @@ class CourseMaterial extends Model protected $casts = ['is_required' => 'boolean']; - public function course(){ + public function course() + { return $this->belongsTo(Course::class, 'course_id', 'course_id'); } - - + public function files() + { + return $this->hasMany(CourseMaterialFile::class, 'course_material_id', 'course_material_id'); + } } diff --git a/laravel/app/Models/CourseMaterialChunk.php b/laravel/app/Models/CourseMaterialChunk.php new file mode 100644 index 00000000..c72d9fde --- /dev/null +++ b/laravel/app/Models/CourseMaterialChunk.php @@ -0,0 +1,25 @@ +belongsTo(CourseMaterialFile::class, 'course_material_file_id'); + } +} diff --git a/laravel/app/Models/CourseMaterialFile.php b/laravel/app/Models/CourseMaterialFile.php new file mode 100644 index 00000000..96282283 --- /dev/null +++ b/laravel/app/Models/CourseMaterialFile.php @@ -0,0 +1,70 @@ + 'boolean', + 'ocr_threshold' => 'integer', + ]; + + public const STATUS_PENDING = 'PENDING'; + public const STATUS_INDEXING = 'INDEXING'; + public const STATUS_INDEXED = 'INDEXED'; + public const STATUS_FAILED = 'FAILED'; + + public function courseMaterial() + { + return $this->belongsTo(CourseMaterial::class, 'course_material_id', 'course_material_id'); + } + + public function uploader() + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + public function chunks() + { + return $this->hasMany(CourseMaterialChunk::class, 'course_material_file_id'); + } + + public function topics() + { + return $this->belongsToMany( + CourseTopic::class, + 'course_material_file_topic', + 'course_material_file_id', + 'course_topic_id' + ); + } + + public function suggestedTopics() + { + return $this->hasMany(SuggestedTopic::class, 'course_material_file_id'); + } +} diff --git a/laravel/app/Models/CourseTopic.php b/laravel/app/Models/CourseTopic.php index c8e36646..b077256c 100644 --- a/laravel/app/Models/CourseTopic.php +++ b/laravel/app/Models/CourseTopic.php @@ -18,6 +18,15 @@ public function course(){ return $this->belongsTo(Course::class, 'course_id', 'course_id'); } + public function course_material_files(){ + return $this->belongsToMany( + CourseMaterialFile::class, + 'course_material_file_topic', + 'course_topic_id', + 'course_material_file_id' + ); + } + } diff --git a/laravel/app/Models/SuggestedTopic.php b/laravel/app/Models/SuggestedTopic.php new file mode 100644 index 00000000..48b23634 --- /dev/null +++ b/laravel/app/Models/SuggestedTopic.php @@ -0,0 +1,30 @@ + 'float', + ]; + + public const STATUS_PENDING = 0; + public const STATUS_CONFIRMED = 1; + public const STATUS_REJECTED = 2; + + public function courseMaterialFile() + { + return $this->belongsTo(CourseMaterialFile::class, 'course_material_file_id', 'course_material_file_id'); + } +} diff --git a/laravel/app/Support/PdfPageRenderer.php b/laravel/app/Support/PdfPageRenderer.php new file mode 100644 index 00000000..a26c9f5f --- /dev/null +++ b/laravel/app/Support/PdfPageRenderer.php @@ -0,0 +1,49 @@ +failed()) { + throw new \RuntimeException("pdftoppm failed for page {$page}: " . $result->errorOutput()); + } + + $files = glob($tmpBase . '*.png'); + if (empty($files)) { + throw new \RuntimeException("pdftoppm produced no output for page {$page}."); + } + + return $files[0]; + } +} diff --git a/laravel/app/Support/SearchedTextHighlighter.php b/laravel/app/Support/SearchedTextHighlighter.php new file mode 100644 index 00000000..b0fbd8b1 --- /dev/null +++ b/laravel/app/Support/SearchedTextHighlighter.php @@ -0,0 +1,19 @@ +', ''], + e($snippet) + ); + } +} diff --git a/laravel/composer.json b/laravel/composer.json index d3398848..4e9e209c 100644 --- a/laravel/composer.json +++ b/laravel/composer.json @@ -21,7 +21,8 @@ "maennchen/zipstream-php": "2.4.0", "myclabs/php-enum": "^1.8", "phpoffice/phpspreadsheet": "^1.29", - "phpoffice/phpword": "^1.3" + "phpoffice/phpword": "^1.3", + "smalot/pdfparser": "^2.12" }, "require-dev": { "fakerphp/faker": "^1.9.1", @@ -56,8 +57,10 @@ "Tests\\": "tests/" }, "files": [ + "tests/Helpers/Shared/Fixtures.php", "tests/Helpers/AiSuggestionsE2E/Fixtures.php", - "tests/Helpers/AiSuggestionsE2E/LOMappingServiceCheck.php" + "tests/Helpers/AiSuggestionsE2E/LOMappingServiceCheck.php", + "tests/Helpers/TextExtractionE2E/Fixtures.php" ] }, "minimum-stability": "stable", @@ -79,7 +82,7 @@ "test": [ "php scripts/check-test-db.php", "php artisan migrate:fresh --seed --env=testing", - "php artisan test --exclude-group=ai-suggestions-e2e" + "php artisan test --exclude-group=ai-suggestions-e2e --exclude-group=text-extraction-e2e" ], "test:ai-suggestions-e2e": [ "Composer\\Config::disableProcessTimeout", @@ -100,6 +103,26 @@ "php scripts/check-test-db.php", "php artisan migrate:fresh --seed --env=testing", "php artisan test --group=ai-suggestions-e2e --debug" + ], + "test:text-extraction-e2e": [ + "Composer\\Config::disableProcessTimeout", + "php scripts/check-test-db.php", + "php artisan migrate:fresh --seed --env=testing", + "php artisan test --group=text-extraction-e2e" + ], + "test:text-extraction-e2e:debug": [ + "Composer\\Config::disableProcessTimeout", + "@putenv PWDEBUG=1", + "php scripts/check-test-db.php", + "php artisan migrate:fresh --seed --env=testing", + "php artisan test --group=text-extraction-e2e --debug" + ], + "test:text-extraction-e2e:visual": [ + "Composer\\Config::disableProcessTimeout", + "@putenv PWDEBUG=0", + "php scripts/check-test-db.php", + "php artisan migrate:fresh --seed --env=testing", + "php artisan test --group=text-extraction-e2e --debug" ] } } diff --git a/laravel/composer.lock b/laravel/composer.lock index fdd87910..ca28dc10 100644 --- a/laravel/composer.lock +++ b/laravel/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "93c271ce03cbb759c41669c56624cd41", + "content-hash": "b181dfad422ee5e29d95182ca2731384", "packages": [ { "name": "balping/json-raw-encoder", @@ -257,28 +257,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -316,7 +317,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -326,13 +327,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "consoletvs/charts", @@ -1074,25 +1071,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.5", + "version": "7.13.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148" + "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148", - "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bcd989ad36c92d42a3715379af91f2defee5b8dd", + "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.3", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1100,8 +1098,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1181,7 +1179,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.5" + "source": "https://github.com/guzzle/guzzle/tree/7.13.2" }, "funding": [ { @@ -1197,24 +1195,25 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:53:46+00:00" + "time": "2026-07-05T19:00:11+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.4.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1264,7 +1263,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "source": "https://github.com/guzzle/promises/tree/2.5.0" }, "funding": [ { @@ -1280,27 +1279,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.3", + "version": "2.12.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", - "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1381,7 +1382,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.3" + "source": "https://github.com/guzzle/psr7/tree/2.12.3" }, "funding": [ { @@ -1397,25 +1398,25 @@ "type": "tidelift" } ], - "time": "2026-05-27T11:48:20+00:00" + "time": "2026-06-23T15:21:08+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.6", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", - "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1467,7 +1468,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" }, "funding": [ { @@ -1483,7 +1484,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T22:00:21+00:00" + "time": "2026-06-23T13:02:23+00:00" }, { "name": "laminas/laminas-escaper", @@ -1763,16 +1764,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.18", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", - "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { @@ -1816,9 +1817,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.18" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" }, - "time": "2026-05-19T00:47:18+00:00" + "time": "2026-06-26T00:11:25+00:00" }, { "name": "laravel/serializable-closure", @@ -2201,16 +2202,16 @@ }, { "name": "league/flysystem", - "version": "3.34.0", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", - "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -2278,9 +2279,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-05-14T10:28:08+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", @@ -2756,16 +2757,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -2773,7 +2774,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -2817,9 +2818,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "monolog/monolog", @@ -2989,16 +2990,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.4", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", - "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", "shasum": "" }, "require": { @@ -3090,7 +3091,7 @@ "type": "tidelift" } ], - "time": "2026-04-07T09:57:54+00:00" + "time": "2026-06-18T13:49:15+00:00" }, { "name": "nette/schema", @@ -3252,20 +3253,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -3304,9 +3304,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/termwind", @@ -3539,16 +3539,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.30.4", + "version": "1.30.5", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9" + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9", - "reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", + "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", "shasum": "" }, "require": { @@ -3641,9 +3641,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.4" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" }, - "time": "2026-04-19T06:00:39+00:00" + "time": "2026-05-31T05:13:11+00:00" }, { "name": "phpoffice/phpword", @@ -4242,16 +4242,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.23", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", - "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -4315,9 +4315,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-05-23T13:41:31+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "ralouphie/getallheaders", @@ -4441,20 +4441,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4513,9 +4513,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "sabberworm/php-css-parser", @@ -4583,22 +4583,73 @@ }, "time": "2025-07-11T13:20:48+00:00" }, + { + "name": "smalot/pdfparser", + "version": "v2.12.5", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354", + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.12.5" + }, + "time": "2026-04-17T11:37:58+00:00" + }, { "name": "symfony/clock", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -4638,7 +4689,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.8" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -4658,20 +4709,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -4736,7 +4787,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -4756,24 +4807,24 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "3665cfade90565430909b906394c73c8739e57d0" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", - "reference": "3665cfade90565430909b906394c73c8739e57d0", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -4805,7 +4856,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -4825,20 +4876,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -4876,7 +4927,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4896,20 +4947,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", - "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { @@ -4958,7 +5009,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -4978,24 +5029,25 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -5043,7 +5095,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -5063,20 +5115,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5123,7 +5175,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5143,20 +5195,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e0be088d22278583a82da281886e8c3592fbf149" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", - "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -5191,7 +5243,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.8" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -5211,20 +5263,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bc354f47c62301e990b7874fa662326368508e2c" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", - "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { @@ -5273,7 +5325,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -5293,20 +5345,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9df847980c436451f4f51d1284491bb4356dd989" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", - "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { @@ -5392,7 +5444,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -5412,20 +5464,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T08:31:43+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.12", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", - "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -5476,7 +5528,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.12" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -5496,7 +5548,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", @@ -5926,16 +5978,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -5987,7 +6039,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -6007,7 +6059,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -6095,16 +6147,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "8339098cae28673c15cce00d80734af0453054e2" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", - "reference": "8339098cae28673c15cce00d80734af0453054e2", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -6151,7 +6203,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -6171,7 +6223,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php85", @@ -6488,16 +6540,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -6551,7 +6603,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -6571,24 +6623,24 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v8.0.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -6641,7 +6693,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.13" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -6661,24 +6713,24 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.10", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -6734,7 +6786,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.10" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -6754,20 +6806,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:30:54+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -6816,7 +6868,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6836,7 +6888,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", @@ -6918,16 +6970,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.8", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", - "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -6981,7 +7033,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -7001,7 +7053,56 @@ "type": "tidelift" } ], - "time": "2026-03-30T13:44:50+00:00" + "time": "2026-06-08T20:24:16+00:00" + }, + { + "name": "thiagoalessio/tesseract_ocr", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/thiagoalessio/tesseract-ocr-for-php.git", + "reference": "232a8cb9d571992f9bd1e263f2f6909cf6c173a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thiagoalessio/tesseract-ocr-for-php/zipball/232a8cb9d571992f9bd1e263f2f6909cf6c173a1", + "reference": "232a8cb9d571992f9bd1e263f2f6909cf6c173a1", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/php-code-coverage": "^2.2.4 || ^9.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "thiagoalessio\\TesseractOCR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "thiagoalessio", + "email": "thiagoalessio@me.com" + } + ], + "description": "A wrapper to work with Tesseract OCR inside PHP.", + "keywords": [ + "OCR", + "Tesseract", + "text recognition" + ], + "support": { + "irc": "irc://irc.freenode.net/tesseract-ocr-for-php", + "issues": "https://github.com/thiagoalessio/tesseract-ocr-for-php/issues", + "source": "https://github.com/thiagoalessio/tesseract-ocr-for-php" + }, + "time": "2023-10-05T21:14:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7220,16 +7321,16 @@ "packages-dev": [ { "name": "amphp/amp", - "version": "v3.1.1", + "version": "v3.1.2", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", + "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb", "shasum": "" }, "require": { @@ -7239,7 +7340,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.23.1" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -7289,7 +7390,7 @@ ], "support": { "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.1.1" + "source": "https://github.com/amphp/amp/tree/v3.1.2" }, "funding": [ { @@ -7297,7 +7398,7 @@ "type": "github" } ], - "time": "2025-08-27T21:42:00+00:00" + "time": "2026-06-21T13:59:44+00:00" }, { "name": "amphp/byte-stream", @@ -7765,16 +7866,16 @@ }, { "name": "amphp/http-server", - "version": "v3.4.5", + "version": "v3.4.6", "source": { "type": "git", "url": "https://github.com/amphp/http-server.git", - "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d" + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d", - "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315", + "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315", "shasum": "" }, "require": { @@ -7850,7 +7951,7 @@ ], "support": { "issues": "https://github.com/amphp/http-server/issues", - "source": "https://github.com/amphp/http-server/tree/v3.4.5" + "source": "https://github.com/amphp/http-server/tree/v3.4.6" }, "funding": [ { @@ -7858,7 +7959,7 @@ "type": "github" } ], - "time": "2026-05-01T03:55:07+00:00" + "time": "2026-06-27T10:31:48+00:00" }, { "name": "amphp/parser", @@ -7924,16 +8025,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.4", + "version": "v1.2.5", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "a044733e080940d1483f56caff0c412ad6982776" + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", - "reference": "a044733e080940d1483f56caff0c412ad6982776", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/92f121dde31cd1d89d5d0f9eba64ac40271b236e", + "reference": "92f121dde31cd1d89d5d0f9eba64ac40271b236e", "shasum": "" }, "require": { @@ -7979,7 +8080,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.4" + "source": "https://github.com/amphp/pipeline/tree/v1.2.5" }, "funding": [ { @@ -7987,20 +8088,20 @@ "type": "github" } ], - "time": "2026-05-06T05:37:57+00:00" + "time": "2026-06-27T14:17:20+00:00" }, { "name": "amphp/process", - "version": "v2.0.3", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/amphp/process.git", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + "reference": "583959df17d00304ad7b0b32285373f985935643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", "shasum": "" }, "require": { @@ -8014,7 +8115,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -8047,7 +8148,7 @@ "homepage": "https://amphp.org/process", "support": { "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v2.0.3" + "source": "https://github.com/amphp/process/tree/v2.1.0" }, "funding": [ { @@ -8055,7 +8156,7 @@ "type": "github" } ], - "time": "2024-04-19T03:13:44+00:00" + "time": "2026-05-31T15:11:55+00:00" }, { "name": "amphp/serialization", @@ -9388,16 +9489,16 @@ }, { "name": "pestphp/pest", - "version": "v4.7.0", + "version": "v4.7.4", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", - "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", + "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb", + "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb", "shasum": "" }, "require": { @@ -9410,21 +9511,21 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.24", - "symfony/process": "^7.4.8|^8.0.8" + "phpunit/phpunit": "^12.5.30", + "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.24", + "phpunit/phpunit": ">12.5.30", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "mrpunyapal/peststan": "^0.2.9", + "mrpunyapal/peststan": "^0.2.10", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", - "psy/psysh": "^0.12.22" + "psy/psysh": "^0.12.23" }, "bin": [ "bin/pest" @@ -9491,7 +9592,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.7.0" + "source": "https://github.com/pestphp/pest/tree/v4.7.4" }, "funding": [ { @@ -9503,7 +9604,7 @@ "type": "github" } ], - "time": "2026-05-03T16:09:32+00:00" + "time": "2026-06-25T19:09:05+00:00" }, { "name": "pestphp/pest-plugin", @@ -10203,16 +10304,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.6", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "876099a072646c7745f673d7aeab5382c4439691" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", - "reference": "876099a072646c7745f673d7aeab5382c4439691", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -10223,13 +10324,13 @@ "php": ">=8.3", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10267,7 +10368,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -10287,7 +10388,7 @@ "type": "tidelift" } ], - "time": "2026-04-15T08:23:17+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10548,16 +10649,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.24", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -10571,20 +10672,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.6", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.0", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -10626,7 +10727,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { @@ -10634,7 +10735,7 @@ "type": "other" } ], - "time": "2026-05-01T04:21:04+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "revolt/event-loop", @@ -11162,26 +11263,26 @@ }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -11212,7 +11313,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -11232,7 +11333,7 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", @@ -12165,16 +12266,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -12225,9 +12326,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], diff --git a/laravel/config/services.php b/laravel/config/services.php index 7f4a4043..7eb3aebc 100644 --- a/laravel/config/services.php +++ b/laravel/config/services.php @@ -39,4 +39,14 @@ 'base_url' => env('LO_MAPPING_SERVICE_URL', 'http://127.0.0.1:8002'), ], + // Extracts text and topics + 'text_extraction' => [ + 'base_url' => env('TEXT_EXTRACTION_SERVICE_URL', 'http://127.0.0.1:8003'), + ], + + // When running on Windows, the php server has trouble finding pdftoppm.exe. + // If pdftoppm is installed in WSL, macOS, or Linux, we + // don't need to add the env var, it just defaults to pdftoppm. + 'pdftoppm' => ['path' => env('PDFTOPPM_PATH', 'pdftoppm')], + ]; diff --git a/laravel/database/migrations/2026_05_08_124043_create_course_material_files_table.php b/laravel/database/migrations/2026_05_08_124043_create_course_material_files_table.php new file mode 100644 index 00000000..df957c2f --- /dev/null +++ b/laravel/database/migrations/2026_05_08_124043_create_course_material_files_table.php @@ -0,0 +1,44 @@ +id('course_material_file_id'); + + $table->unsignedBigInteger('course_material_id'); + $table->foreign('course_material_id') + ->references('course_material_id') + ->on('course_materials') + ->onDelete('cascade'); + + $table->unsignedBigInteger('uploaded_by')->nullable(); + $table->foreign('uploaded_by')->references('id')->on('users')->onDelete('set null'); + + $table->string('file_name'); + $table->string('file_path'); + $table->unsignedBigInteger('file_size'); + + $table->string('status')->default('PENDING'); + $table->text('error_message')->nullable(); + $table->unsignedInteger('page_count')->nullable(); + + $table->boolean('ocr_enabled')->default(false); + $table->unsignedInteger('ocr_threshold')->default(0); + $table->string('extraction_engine')->default('tesseract'); + $table->unsignedInteger('processing_time_seconds')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_material_files'); + } +}; diff --git a/laravel/database/migrations/2026_05_20_100001_create_course_material_chunks_table.php b/laravel/database/migrations/2026_05_20_100001_create_course_material_chunks_table.php new file mode 100644 index 00000000..70516ad0 --- /dev/null +++ b/laravel/database/migrations/2026_05_20_100001_create_course_material_chunks_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('course_material_file_id'); + $table->foreign('course_material_file_id') + ->references('course_material_file_id') + ->on('course_material_files') + ->onDelete('cascade'); + $table->unsignedInteger('page_number'); + $table->unsignedInteger('chunk_index')->default(0); + $table->text('content'); + $table->timestamps(); + + $table->unique(['course_material_file_id', 'page_number', 'chunk_index'], 'course_material_chunks_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_material_chunks'); + } +}; diff --git a/laravel/database/migrations/2026_06_08_150930_add_tsvector_to_course_material_chunks.php b/laravel/database/migrations/2026_06_08_150930_add_tsvector_to_course_material_chunks.php new file mode 100644 index 00000000..868ba6a9 --- /dev/null +++ b/laravel/database/migrations/2026_06_08_150930_add_tsvector_to_course_material_chunks.php @@ -0,0 +1,27 @@ +unsignedBigInteger('course_material_file_id'); + $table->unsignedBigInteger('course_topic_id'); + + $table->foreign('course_material_file_id') + ->references('course_material_file_id')->on('course_material_files') + ->onDelete('cascade'); + $table->foreign('course_topic_id') + ->references('course_topic_id')->on('course_topics') + ->onDelete('cascade'); + + $table->primary(['course_material_file_id', 'course_topic_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_material_file_topic'); + } +}; \ No newline at end of file diff --git a/laravel/database/migrations/2026_07_16_120000_create_suggested_topics_table.php b/laravel/database/migrations/2026_07_16_120000_create_suggested_topics_table.php new file mode 100644 index 00000000..ef66514e --- /dev/null +++ b/laravel/database/migrations/2026_07_16_120000_create_suggested_topics_table.php @@ -0,0 +1,32 @@ +id('suggested_topic_id'); + $table->unsignedBigInteger('course_material_file_id'); + $table->text('topic'); + $table->float('score')->nullable(); + $table->string('source')->default('keyword'); + $table->unsignedTinyInteger('status')->default(0); // 0=pending, 1=confirmed, 2=rejected + $table->timestamps(); + + $table->foreign('course_material_file_id') + ->references('course_material_file_id')->on('course_material_files') + ->onDelete('cascade'); + + $table->unique(['course_material_file_id', 'topic']); + }); + } + + public function down(): void + { + Schema::dropIfExists('suggested_topics'); + } +}; diff --git a/laravel/resources/views/courses/coverageAnalysis.blade.php b/laravel/resources/views/courses/coverageAnalysis.blade.php new file mode 100644 index 00000000..99a15006 --- /dev/null +++ b/laravel/resources/views/courses/coverageAnalysis.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.app') + +@section('content') +
+
+
+

Course: {{ $course->year }} {{ $course->semester }} {{ $course->course_code }} {{ $course->course_num }} {{ $course->section }}

+
{{ $course->course_title }}
+
+ +
+ +
+
Search (Temp)
+
+
+ + +
+
+
+ + @if (session('search_results') !== null) +
+
+ Search results for {{ session('search_query') }} + ({{ session('search_results')->count() }} result(s)) +
+
+ @include('partials.search-result', ['results' => session('search_results')]) +
+
+ @endif +
+@endsection diff --git a/laravel/resources/views/courses/material_file.blade.php b/laravel/resources/views/courses/material_file.blade.php new file mode 100644 index 00000000..3d9bd9fd --- /dev/null +++ b/laravel/resources/views/courses/material_file.blade.php @@ -0,0 +1,567 @@ +@extends('layouts.app') + +@section('content') + +@php + $failed = $file->status === 'FAILED'; +@endphp + +
+
+
+

+ {{ $file->file_name }} +

+

+ {{ $file->courseMaterial->name }} +

+
+ +
+ +
+
+
File Details
+
+ @if ($failed) +
+ @csrf + +
+ @endif + @if ($file->ocr_enabled) + @php + if ($file->extraction_engine === 'textract') { + $ocrLabel = 'OCR (AWS)'; + $ocrTip = 'AWS Textract'; + } else { + $ocrLabel = 'OCR'; + $ocrTip = 'Tesseract'; + } + if ($file->processing_time_seconds !== null) { + $ocrTip .= ' (' . $file->processing_time_seconds . 's)'; + } + @endphp + + {{ $ocrLabel }} + + @endif + @switch($file->status) + @case('INDEXED') + Indexed + @break + @case('INDEXING') + Indexing + @break + @case('PENDING') + Pending + @break + @case('FAILED') + Failed + @break + @endswitch +
+
+
+
+
Pages
+
{{ $file->page_count ?? 'Unknown' }}
+ +
File size
+
{{ number_format($file->file_size / 1024, 1) }} KB
+ +
Uploaded
+
+ {{ $file->created_at->format('Y-m-d H:i') }} + @if ($file->uploader) by {{ $file->uploader->name }} @endif +
+ +
Extraction method
+
+ @if ($file->ocr_enabled) + {{ $file->extraction_engine === 'textract' ? 'AWS Textract' : 'Tesseract OCR' }} + @if ($file->extraction_engine !== 'textract') + (threshold: {{ $file->ocr_threshold }} chars) + @endif + @else + Text extraction + @endif +
+ + + + @if ($file->status === 'FAILED' && $file->error_message) +
Error
+
{{ $file->error_message }}
+ @endif +
+ + +
+
+ +
+
+
+ Suggested Topics + @if ($suggestedTopics->isNotEmpty()) + {{ $suggestedTopics->count() }} + @endif +
+
+
+ @csrf + +
+ @if ($suggestedTopics->isNotEmpty()) +
+ @csrf + +
+
+ @csrf + +
+ + @endif +
+
+
+ @if ($suggestedTopics->isEmpty()) +

Click Refresh Topics to suggest topics based on file content.

+ @else +

Review the topics extracted from this file. Tick to confirm, cross to reject.

+
+ + + + + + + + + @foreach ($suggestedTopics as $suggested) + + + + + @endforeach + +
TopicActions
+ {{ $suggested->topic }} + + + + +
+
+ @endif +
+
+ +
+
+
+
Topics
+
+ + + + +
+
+
+ +
+
+ + + + + + + + + @forelse ($file->topics as $topic) + + + + + @empty +
+ @endforelse + +
TopicActions
+ {{ $topic->topic }} + + +
+
+
+
+ +
+
+
Extracted Text
+ {{ $file->chunks->count() }} page(s) +
+
+ @if ($file->status === 'PENDING' || $file->status === 'INDEXING') +
+ This file is still being indexed. Refresh in a moment. +
+ @elseif ($file->status === 'FAILED') +
+ Indexing failed. + @if ($file->error_message) +
{{ $file->error_message }}
+ @endif +
+ @elseif ($file->chunks->isEmpty()) + @if ($file->ocr_enabled) +

No text was extracted. Try increasing the OCR threshold or making the scan clearer. If OCR is not required, please try re-uploading with OCR disabled.

+ @else +

No text was extracted. Try re-uploading with the OCR option enabled.

+ @endif + @else +

+ Showing raw extracted text per page. +

+ @foreach ($file->chunks as $chunk) +
+ + Page {{ $chunk->page_number }} + ({{ str_word_count($chunk->content) }} words) + +
{{ $chunk->content }}
+
+ @endforeach + @endif +
+
+
+ + + + + +@endsection diff --git a/laravel/resources/views/courses/wizard/header.blade.php b/laravel/resources/views/courses/wizard/header.blade.php index f5a06e30..4075b5cf 100644 --- a/laravel/resources/views/courses/wizard/header.blade.php +++ b/laravel/resources/views/courses/wizard/header.blade.php @@ -395,6 +395,14 @@ class="form-control @error('course_section') is-invalid @enderror" @endif +
+
+ + + +
+
+ @if (!$isViewer) diff --git a/laravel/resources/views/courses/wizard/step10.blade.php b/laravel/resources/views/courses/wizard/step10.blade.php index b494ddfb..60d1dfda 100644 --- a/laravel/resources/views/courses/wizard/step10.blade.php +++ b/laravel/resources/views/courses/wizard/step10.blade.php @@ -1,308 +1,711 @@ @extends('layouts.app') @section('content') -
- @include('courses.wizard.header') -
-
- -
-
-

- Course Materials -
- -
-
- @include('layouts.guide') -
-

-
- -