From 8e2234b7eaf9d7fa5d587d090d2739cde6fe5713 Mon Sep 17 00:00:00 2001 From: Ilyes BEN BRIK Date: Sat, 18 Jul 2026 01:13:56 +0200 Subject: [PATCH 1/2] config sonar actions --- .github/workflows/build.yml | 19 +++++++++++++++++++ sonar-project.properties | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f51a0dd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,19 @@ +name: Build +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index 762ce3a..7d7ae6a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,7 @@ -sonar.projectKey=bookoholik -sonar.projectName=Bookoholik -sonar.projectVersion=1.0.0 + +sonar.projectKey=XKlibure_home-library +sonar.organization=xklibure +sonar.projectVersion=0.1.0 sonar.sources=backend/app,frontend/src sonar.sourceEncoding=UTF-8 From e92b8c1d03bbe756a071a9f70b159666a2633ae4 Mon Sep 17 00:00:00 2001 From: Ilyes BEN BRIK Date: Sat, 18 Jul 2026 17:50:01 +0200 Subject: [PATCH 2/2] New features, security patches --- .env.example | 4 + backend/app/Controllers/BooksController.php | 4 +- .../app/Controllers/LocationsController.php | 449 ++++++++++++++++++ .../app/Controllers/PublishersController.php | 153 ++++++ backend/app/Controllers/ScanController.php | 381 +++++++++++++++ backend/app/Middleware/UserMiddleware.php | 31 ++ backend/routes/api.php | 31 ++ docker-compose.yml | 2 + docker/Dockerfile.backend | 15 + docker/init.sql | 114 ++++- docker/nginx.conf | 4 +- frontend/src/App.vue | 54 ++- frontend/src/i18n/ar.js | 34 ++ frontend/src/i18n/en.js | 90 ++++ frontend/src/i18n/fr.js | 34 ++ frontend/src/router/index.js | 20 + frontend/src/services/api.js | 4 +- frontend/src/views/BookFormView.vue | 8 + frontend/src/views/BooksView.vue | 18 +- frontend/src/views/LocationsView.vue | 268 +++++++++++ frontend/src/views/PublishersView.vue | 170 +++++++ frontend/src/views/ScanBookView.vue | 390 +++++++++++++++ sonar-project.properties | 7 +- 23 files changed, 2229 insertions(+), 56 deletions(-) create mode 100644 backend/app/Controllers/LocationsController.php create mode 100644 backend/app/Controllers/PublishersController.php create mode 100644 backend/app/Controllers/ScanController.php create mode 100644 backend/app/Middleware/UserMiddleware.php create mode 100644 frontend/src/views/LocationsView.vue create mode 100644 frontend/src/views/PublishersView.vue create mode 100644 frontend/src/views/ScanBookView.vue diff --git a/.env.example b/.env.example index db6b8d3..73c206e 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,7 @@ DB_PORT=5432 # API URL (as seen by the browser) API_URL=http://localhost:8080/api + +# Google Gemini API Key (free at https://aistudio.google.com/apikey) +# Required for book cover scanning feature. Falls back to Tesseract OCR if empty. +GEMINI_API_KEY= diff --git a/backend/app/Controllers/BooksController.php b/backend/app/Controllers/BooksController.php index cc199ff..d2de7b3 100644 --- a/backend/app/Controllers/BooksController.php +++ b/backend/app/Controllers/BooksController.php @@ -224,7 +224,7 @@ public function store(array $params): void 'language' => $language, 'location_room' => $this->sanitize($data['location_room'] ?? ''), 'location_shelf' => $this->sanitize($data['location_shelf'] ?? ''), - 'read_status' => (bool)($data['read_status'] ?? false), + 'read_status' => filter_var($data['read_status'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 't' : 'f', 'isbn' => $this->sanitize($data['isbn'] ?? ''), 'edition_house' => $this->sanitize($data['edition_house'] ?? ''), 'num_pages' => !empty($data['num_pages']) ? (int)$data['num_pages'] : null, @@ -276,7 +276,7 @@ public function update(array $params): void if (in_array($field, ['publication_year', 'num_pages', 'series_position'])) { $bindings[$field] = !empty($data[$field]) ? (int)$data[$field] : null; } elseif ($field === 'read_status') { - $bindings[$field] = (bool)$data[$field]; + $bindings[$field] = filter_var($data[$field], FILTER_VALIDATE_BOOLEAN) ? 't' : 'f'; } else { $bindings[$field] = is_string($data[$field]) ? $this->sanitize($data[$field]) : $data[$field]; } diff --git a/backend/app/Controllers/LocationsController.php b/backend/app/Controllers/LocationsController.php new file mode 100644 index 0000000..3762b51 --- /dev/null +++ b/backend/app/Controllers/LocationsController.php @@ -0,0 +1,449 @@ +query(' + SELECT a.*, + (SELECT COUNT(*) FROM rooms WHERE address_id = a.id) as rooms_count, + (SELECT COUNT(*) FROM shelves s JOIN rooms r ON s.room_id = r.id WHERE r.address_id = a.id) as shelves_count, + (SELECT COUNT(*) FROM books b JOIN shelves s ON b.shelf_id = s.id JOIN rooms r ON s.room_id = r.id WHERE r.address_id = a.id) as books_count + FROM addresses a + ORDER BY a.is_primary DESC, a.name ASC + '); + $this->json(['data' => $stmt->fetchAll()]); + } + + /** + * GET /api/locations/{id} + * Get address with its rooms and shelves + */ + public function show(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('SELECT * FROM addresses WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + $address = $stmt->fetch(); + + if (!$address) { + $this->json(['error' => 'Address not found.'], 404); + return; + } + + // Get rooms with their shelves + $stmt = $db->prepare(' + SELECT r.*, + (SELECT COUNT(*) FROM shelves WHERE room_id = r.id) as shelves_count, + (SELECT COUNT(*) FROM books b JOIN shelves s ON b.shelf_id = s.id WHERE s.room_id = r.id) as books_count + FROM rooms r + WHERE r.address_id = :address_id + ORDER BY r.name + '); + $stmt->execute(['address_id' => $params['id']]); + $address['rooms'] = $stmt->fetchAll(); + + // Get shelves for each room + foreach ($address['rooms'] as &$room) { + $stmt = $db->prepare(' + SELECT s.*, + (SELECT COUNT(*) FROM books WHERE shelf_id = s.id) as books_count + FROM shelves s + WHERE s.room_id = :room_id + ORDER BY s.name + '); + $stmt->execute(['room_id' => $room['id']]); + $room['shelves'] = $stmt->fetchAll(); + } + + $this->json(['data' => $address]); + } + + /** + * POST /api/locations + */ + public function store(array $params): void + { + $data = $this->getRequestBody(); + + if (empty($data['name'])) { + $this->json(['error' => 'Address name is required.'], 422); + return; + } + + $db = Database::getConnection(); + + // If setting as primary, unset others + if (!empty($data['is_primary'])) { + $db->exec("UPDATE addresses SET is_primary = FALSE"); + } + + $stmt = $db->prepare(' + INSERT INTO addresses (name, street, city, state_province, postal_code, country, is_primary, notes) + VALUES (:name, :street, :city, :state_province, :postal_code, :country, :is_primary, :notes) + RETURNING * + '); + $stmt->execute([ + 'name' => $this->sanitize($data['name']), + 'street' => $this->sanitize($data['street'] ?? ''), + 'city' => $this->sanitize($data['city'] ?? ''), + 'state_province' => $this->sanitize($data['state_province'] ?? ''), + 'postal_code' => $this->sanitize($data['postal_code'] ?? ''), + 'country' => $this->sanitize($data['country'] ?? ''), + 'is_primary' => filter_var($data['is_primary'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 't' : 'f', + 'notes' => $data['notes'] ?? '', + ]); + + $this->json(['message' => 'Address created.', 'data' => $stmt->fetch()], 201); + } + + /** + * PUT /api/locations/{id} + */ + public function update(array $params): void + { + $data = $this->getRequestBody(); + $db = Database::getConnection(); + + $stmt = $db->prepare('SELECT id FROM addresses WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + if (!$stmt->fetch()) { + $this->json(['error' => 'Address not found.'], 404); + return; + } + + // If setting as primary, unset others + if (!empty($data['is_primary'])) { + $db->exec("UPDATE addresses SET is_primary = FALSE"); + } + + $fields = ['name', 'street', 'city', 'state_province', 'postal_code', 'country', 'notes']; + $updates = []; + $bindings = ['id' => $params['id']]; + + foreach ($fields as $field) { + if (array_key_exists($field, $data)) { + $updates[] = "{$field} = :{$field}"; + $bindings[$field] = $this->sanitize($data[$field] ?? ''); + } + } + if (array_key_exists('is_primary', $data)) { + $updates[] = "is_primary = :is_primary"; + $bindings['is_primary'] = filter_var($data['is_primary'], FILTER_VALIDATE_BOOLEAN) ? 't' : 'f'; + } + + if (empty($updates)) { + $this->json(['error' => 'No fields to update.'], 422); + return; + } + + $updates[] = "updated_at = NOW()"; + $sql = "UPDATE addresses SET " . implode(', ', $updates) . " WHERE id = :id RETURNING *"; + $stmt = $db->prepare($sql); + $stmt->execute($bindings); + + $this->json(['message' => 'Address updated.', 'data' => $stmt->fetch()]); + } + + /** + * DELETE /api/locations/{id} + */ + public function destroy(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('DELETE FROM addresses WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + + if ($stmt->rowCount() === 0) { + $this->json(['error' => 'Address not found.'], 404); + return; + } + + $this->json(['message' => 'Address deleted.']); + } + + // ===== ROOMS ===== + + /** + * GET /api/locations/{id}/rooms + */ + public function listRooms(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare(' + SELECT r.*, + (SELECT COUNT(*) FROM shelves WHERE room_id = r.id) as shelves_count, + (SELECT COUNT(*) FROM books b JOIN shelves s ON b.shelf_id = s.id WHERE s.room_id = r.id) as books_count + FROM rooms r + WHERE r.address_id = :address_id + ORDER BY r.name + '); + $stmt->execute(['address_id' => $params['id']]); + $this->json(['data' => $stmt->fetchAll()]); + } + + /** + * POST /api/rooms + */ + public function storeRoom(array $params): void + { + $data = $this->getRequestBody(); + + $errors = $this->validateRequired($data, ['address_id', 'name']); + if ($errors) { + $this->json(['errors' => $errors], 422); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare(' + INSERT INTO rooms (address_id, name, description, floor) + VALUES (:address_id, :name, :description, :floor) + RETURNING * + '); + $stmt->execute([ + 'address_id' => $data['address_id'], + 'name' => $this->sanitize($data['name']), + 'description' => $this->sanitize($data['description'] ?? ''), + 'floor' => $this->sanitize($data['floor'] ?? ''), + ]); + + $this->json(['message' => 'Room created.', 'data' => $stmt->fetch()], 201); + } + + /** + * PUT /api/rooms/{id} + */ + public function updateRoom(array $params): void + { + $data = $this->getRequestBody(); + $db = Database::getConnection(); + + $fields = ['name', 'description', 'floor']; + $updates = []; + $bindings = ['id' => $params['id']]; + + foreach ($fields as $field) { + if (array_key_exists($field, $data)) { + $updates[] = "{$field} = :{$field}"; + $bindings[$field] = $this->sanitize($data[$field] ?? ''); + } + } + + if (empty($updates)) { + $this->json(['error' => 'No fields to update.'], 422); + return; + } + + $updates[] = "updated_at = NOW()"; + $sql = "UPDATE rooms SET " . implode(', ', $updates) . " WHERE id = :id RETURNING *"; + $stmt = $db->prepare($sql); + $stmt->execute($bindings); + $room = $stmt->fetch(); + + if (!$room) { + $this->json(['error' => 'Room not found.'], 404); + return; + } + + $this->json(['message' => 'Room updated.', 'data' => $room]); + } + + /** + * DELETE /api/rooms/{id} + */ + public function destroyRoom(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('DELETE FROM rooms WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + + if ($stmt->rowCount() === 0) { + $this->json(['error' => 'Room not found.'], 404); + return; + } + + $this->json(['message' => 'Room deleted.']); + } + + // ===== SHELVES ===== + + /** + * GET /api/rooms/{id}/shelves + */ + public function listShelves(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare(' + SELECT s.*, + (SELECT COUNT(*) FROM books WHERE shelf_id = s.id) as books_count + FROM shelves s + WHERE s.room_id = :room_id + ORDER BY s.name + '); + $stmt->execute(['room_id' => $params['id']]); + $this->json(['data' => $stmt->fetchAll()]); + } + + /** + * POST /api/shelves + */ + public function storeShelf(array $params): void + { + $data = $this->getRequestBody(); + + $errors = $this->validateRequired($data, ['room_id', 'name']); + if ($errors) { + $this->json(['errors' => $errors], 422); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare(' + INSERT INTO shelves (room_id, name, description, capacity) + VALUES (:room_id, :name, :description, :capacity) + RETURNING * + '); + $stmt->execute([ + 'room_id' => $data['room_id'], + 'name' => $this->sanitize($data['name']), + 'description' => $this->sanitize($data['description'] ?? ''), + 'capacity' => !empty($data['capacity']) ? (int)$data['capacity'] : null, + ]); + + $this->json(['message' => 'Shelf created.', 'data' => $stmt->fetch()], 201); + } + + /** + * PUT /api/shelves/{id} + */ + public function updateShelf(array $params): void + { + $data = $this->getRequestBody(); + $db = Database::getConnection(); + + $fields = ['name', 'description']; + $updates = []; + $bindings = ['id' => $params['id']]; + + foreach ($fields as $field) { + if (array_key_exists($field, $data)) { + $updates[] = "{$field} = :{$field}"; + $bindings[$field] = $this->sanitize($data[$field] ?? ''); + } + } + if (array_key_exists('capacity', $data)) { + $updates[] = "capacity = :capacity"; + $bindings['capacity'] = !empty($data['capacity']) ? (int)$data['capacity'] : null; + } + + if (empty($updates)) { + $this->json(['error' => 'No fields to update.'], 422); + return; + } + + $updates[] = "updated_at = NOW()"; + $sql = "UPDATE shelves SET " . implode(', ', $updates) . " WHERE id = :id RETURNING *"; + $stmt = $db->prepare($sql); + $stmt->execute($bindings); + $shelf = $stmt->fetch(); + + if (!$shelf) { + $this->json(['error' => 'Shelf not found.'], 404); + return; + } + + $this->json(['message' => 'Shelf updated.', 'data' => $shelf]); + } + + /** + * DELETE /api/shelves/{id} + */ + public function destroyShelf(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('DELETE FROM shelves WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + + if ($stmt->rowCount() === 0) { + $this->json(['error' => 'Shelf not found.'], 404); + return; + } + + $this->json(['message' => 'Shelf deleted.']); + } + + // ===== HELPER: Get full location tree (for dropdowns) ===== + + /** + * GET /api/locations/tree + * Returns complete hierarchy for select dropdowns + */ + public function tree(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->query(' + SELECT a.id as address_id, a.name as address_name, + r.id as room_id, r.name as room_name, + s.id as shelf_id, s.name as shelf_name + FROM addresses a + LEFT JOIN rooms r ON r.address_id = a.id + LEFT JOIN shelves s ON s.room_id = r.id + ORDER BY a.name, r.name, s.name + '); + $rows = $stmt->fetchAll(); + + // Build tree structure + $tree = []; + foreach ($rows as $row) { + $addrId = $row['address_id']; + if (!isset($tree[$addrId])) { + $tree[$addrId] = [ + 'id' => $addrId, + 'name' => $row['address_name'], + 'rooms' => [], + ]; + } + if ($row['room_id']) { + $roomId = $row['room_id']; + if (!isset($tree[$addrId]['rooms'][$roomId])) { + $tree[$addrId]['rooms'][$roomId] = [ + 'id' => $roomId, + 'name' => $row['room_name'], + 'shelves' => [], + ]; + } + if ($row['shelf_id']) { + $tree[$addrId]['rooms'][$roomId]['shelves'][] = [ + 'id' => $row['shelf_id'], + 'name' => $row['shelf_name'], + ]; + } + } + } + + // Convert associative to indexed arrays + $result = []; + foreach ($tree as $addr) { + $addr['rooms'] = array_values($addr['rooms']); + $result[] = $addr; + } + + $this->json(['data' => $result]); + } +} diff --git a/backend/app/Controllers/PublishersController.php b/backend/app/Controllers/PublishersController.php new file mode 100644 index 0000000..b8b50dc --- /dev/null +++ b/backend/app/Controllers/PublishersController.php @@ -0,0 +1,153 @@ +getQueryParams(); + $db = Database::getConnection(); + + $conditions = []; + $bindings = []; + + if (!empty($query['search'])) { + $search = '%' . $query['search'] . '%'; + $conditions[] = "(name ILIKE :search OR name_ar ILIKE :search OR city ILIKE :search)"; + $bindings['search'] = $search; + } + + $whereClause = !empty($conditions) ? 'WHERE ' . implode(' AND ', $conditions) : ''; + $sql = "SELECT p.*, + (SELECT COUNT(*) FROM books WHERE publisher_id = p.id) as books_count + FROM publishers p + {$whereClause} + ORDER BY p.name ASC"; + + $stmt = $db->prepare($sql); + $stmt->execute($bindings); + + $this->json(['data' => $stmt->fetchAll()]); + } + + /** + * GET /api/publishers/{id} + */ + public function show(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('SELECT * FROM publishers WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + $publisher = $stmt->fetch(); + + if (!$publisher) { + $this->json(['error' => 'Publisher not found.'], 404); + return; + } + + $this->json(['data' => $publisher]); + } + + /** + * POST /api/publishers + */ + public function store(array $params): void + { + $data = $this->getRequestBody(); + + if (empty($data['name'])) { + $this->json(['error' => 'Publisher name is required.'], 422); + return; + } + + $db = Database::getConnection(); + + $stmt = $db->prepare(' + INSERT INTO publishers (name, name_ar, name_fr, address, city, country, phone, email, website, notes) + VALUES (:name, :name_ar, :name_fr, :address, :city, :country, :phone, :email, :website, :notes) + RETURNING * + '); + $stmt->execute([ + 'name' => $this->sanitize($data['name']), + 'name_ar' => $this->sanitize($data['name_ar'] ?? ''), + 'name_fr' => $this->sanitize($data['name_fr'] ?? ''), + 'address' => $this->sanitize($data['address'] ?? ''), + 'city' => $this->sanitize($data['city'] ?? ''), + 'country' => $this->sanitize($data['country'] ?? ''), + 'phone' => $this->sanitize($data['phone'] ?? ''), + 'email' => $this->sanitize($data['email'] ?? ''), + 'website' => $this->sanitize($data['website'] ?? ''), + 'notes' => $data['notes'] ?? '', + ]); + + $publisher = $stmt->fetch(); + $this->json(['message' => 'Publisher created successfully.', 'data' => $publisher], 201); + } + + /** + * PUT /api/publishers/{id} + */ + public function update(array $params): void + { + $data = $this->getRequestBody(); + $db = Database::getConnection(); + + $stmt = $db->prepare('SELECT * FROM publishers WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + if (!$stmt->fetch()) { + $this->json(['error' => 'Publisher not found.'], 404); + return; + } + + $fields = ['name', 'name_ar', 'name_fr', 'address', 'city', 'country', 'phone', 'email', 'website', 'notes']; + $updates = []; + $bindings = ['id' => $params['id']]; + + foreach ($fields as $field) { + if (array_key_exists($field, $data)) { + $updates[] = "{$field} = :{$field}"; + $bindings[$field] = is_string($data[$field]) ? $this->sanitize($data[$field]) : $data[$field]; + } + } + + if (empty($updates)) { + $this->json(['error' => 'No fields to update.'], 422); + return; + } + + $updates[] = "updated_at = NOW()"; + $sql = "UPDATE publishers SET " . implode(', ', $updates) . " WHERE id = :id RETURNING *"; + + $stmt = $db->prepare($sql); + $stmt->execute($bindings); + + $this->json(['message' => 'Publisher updated successfully.', 'data' => $stmt->fetch()]); + } + + /** + * DELETE /api/publishers/{id} + */ + public function destroy(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare('DELETE FROM publishers WHERE id = :id'); + $stmt->execute(['id' => $params['id']]); + + if ($stmt->rowCount() === 0) { + $this->json(['error' => 'Publisher not found.'], 404); + return; + } + + $this->json(['message' => 'Publisher deleted successfully.']); + } +} diff --git a/backend/app/Controllers/ScanController.php b/backend/app/Controllers/ScanController.php new file mode 100644 index 0000000..557bc8f --- /dev/null +++ b/backend/app/Controllers/ScanController.php @@ -0,0 +1,381 @@ +getRequestBody(); + + if (empty($data['image'])) { + $this->json(['error' => 'No image provided.'], 422); + return; + } + + // Decode base64 image + $imageData = $this->decodeBase64Image($data['image']); + if (!$imageData) { + $this->json(['error' => 'Invalid image format.'], 422); + return; + } + + // Extract book info using AI vision + $parsed = $this->analyzeWithGemini($imageData, 'cover'); + + if (!$parsed) { + $this->json(['error' => 'Could not analyze image. Check GEMINI_API_KEY configuration.'], 500); + return; + } + + // Search database for matches + $matches = $this->searchBooks($parsed['title'] ?? '', $parsed['author'] ?? ''); + + $this->json([ + 'data' => [ + 'parsed' => $parsed, + 'matches' => $matches, + 'found' => count($matches) > 0, + ] + ]); + } + + /** + * POST /api/scan/back + * Scan back cover/last page to extract ISBN, publisher, year + */ + public function scanBack(array $params): void + { + $data = $this->getRequestBody(); + + if (empty($data['image'])) { + $this->json(['error' => 'No image provided.'], 422); + return; + } + + // Decode base64 image + $imageData = $this->decodeBase64Image($data['image']); + if (!$imageData) { + $this->json(['error' => 'Invalid image format.'], 422); + return; + } + + // Extract book info using AI vision + $parsed = $this->analyzeWithGemini($imageData, 'back'); + + if (!$parsed) { + $this->json(['error' => 'Could not analyze image. Check GEMINI_API_KEY configuration.'], 500); + return; + } + + // If ISBN found, search database + $matches = []; + if (!empty($parsed['isbn'])) { + $matches = $this->searchByIsbn($parsed['isbn']); + } + if (empty($matches) && !empty($parsed['title'])) { + $matches = $this->searchBooks($parsed['title'] ?? '', $parsed['author'] ?? ''); + } + + $this->json([ + 'data' => [ + 'parsed' => $parsed, + 'matches' => $matches, + 'found' => count($matches) > 0, + ] + ]); + } + + /** + * Analyze image using Google Gemini Flash Vision API + */ + private function analyzeWithGemini(string $imageData, string $mode): ?array + { + $apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; + if (empty($apiKey)) { + // Fall back to Tesseract if no API key + return $this->analyzeWithTesseract($imageData, $mode); + } + + // Determine MIME type + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->buffer($imageData); + + // Build the prompt based on scan mode + if ($mode === 'cover') { + $prompt = "This is a photo of a book's front cover. Extract the following information:\n" . + "- title: The book title (in original language, keep Arabic/French as-is)\n" . + "- author: The author name (in original language)\n\n" . + "The book may be in Arabic (العربية), French, or English.\n" . + "Return ONLY valid JSON with keys: title, author\n" . + "If you cannot determine a field, use empty string.\n" . + "Example: {\"title\": \"السنة النبوية\", \"author\": \"محمد الغزالي\"}"; + } else { + $prompt = "This is a photo of a book's back cover or last page. Extract the following information:\n" . + "- isbn: The ISBN number (10 or 13 digits, numbers only)\n" . + "- edition_house: The publisher/editor name (in original language)\n" . + "- publication_year: The year of publication (4 digits)\n" . + "- title: The book title if visible\n" . + "- author: The author name if visible\n\n" . + "The book may be in Arabic (العربية), French, or English.\n" . + "Return ONLY valid JSON with keys: isbn, edition_house, publication_year, title, author\n" . + "If you cannot determine a field, use empty string or null for year.\n" . + "Example: {\"isbn\": \"9782070360024\", \"edition_house\": \"دار الشروق\", \"publication_year\": 2005, \"title\": \"\", \"author\": \"\"}"; + } + + // Call Gemini API + $model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key=" . $apiKey; + + $payload = [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => base64_encode($imageData), + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'temperature' => 0.1, + 'maxOutputTokens' => 500, + ] + ]; + + $jsonPayload = json_encode($payload); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $jsonPayload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($httpCode === 429) { + // Rate limited — inform user instead of falling back to poor OCR + return ['title' => '', 'author' => '', '_error' => 'AI rate limit reached. Please wait a moment and try again.']; + } + + if ($httpCode !== 200 || !$response) { + error_log("Gemini API error: HTTP $httpCode - $curlError - $response"); + // Fall back to Tesseract + return $this->analyzeWithTesseract($imageData, $mode); + } + + // Parse Gemini response + $result = json_decode($response, true); + $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + // Extract JSON from response (Gemini sometimes wraps in markdown code blocks) + if (preg_match('/\{[^}]+\}/s', $text, $matches)) { + $parsed = json_decode($matches[0], true); + if ($parsed) { + return $parsed; + } + } + + // Try the full text as JSON + $parsed = json_decode($text, true); + if ($parsed) { + return $parsed; + } + + error_log("Gemini response parse failed: $text"); + return $this->analyzeWithTesseract($imageData, $mode); + } + + /** + * Fallback: Analyze with Tesseract OCR + */ + private function analyzeWithTesseract(string $imageData, string $mode): ?array + { + $tempFile = tempnam(sys_get_temp_dir(), 'scan_'); + file_put_contents($tempFile, $imageData); + + $extractedText = $this->runOcr($tempFile); + unlink($tempFile); + + if (empty($extractedText)) { + return ['title' => '', 'author' => '', 'isbn' => '', 'edition_house' => '', 'publication_year' => null]; + } + + if ($mode === 'cover') { + return $this->parseCoverText($extractedText); + } else { + return $this->parseBackText($extractedText); + } + } + + /** + * Run Tesseract OCR (fallback) + */ + private function runOcr(string $filePath): string + { + $outputBase = tempnam(sys_get_temp_dir(), 'ocr_'); + $command = sprintf( + 'tesseract %s %s -l ara+eng+fra --psm 6 --oem 1 2>/dev/null', + escapeshellarg($filePath), + escapeshellarg($outputBase) + ); + exec($command); + + $ocrText = ''; + $outputFile = $outputBase . '.txt'; + if (file_exists($outputFile)) { + $ocrText = trim(file_get_contents($outputFile)); + unlink($outputFile); + } + if (file_exists($outputBase)) { + unlink($outputBase); + } + return $ocrText; + } + + /** + * Parse cover text (Tesseract fallback) + */ + private function parseCoverText(string $text): array + { + $lines = array_filter(array_map('trim', explode("\n", $text)), fn($l) => mb_strlen(trim($l)) >= 2); + $lines = array_values($lines); + + $title = ''; + $author = ''; + + if (count($lines) >= 2) { + // Longest line is likely the title + $longestIdx = 0; + $longestLen = 0; + foreach ($lines as $idx => $line) { + if (mb_strlen($line) > $longestLen) { + $longestLen = mb_strlen($line); + $longestIdx = $idx; + } + } + $title = $lines[$longestIdx]; + foreach ($lines as $idx => $line) { + if ($idx !== $longestIdx) { + $author = $line; + break; + } + } + } elseif (count($lines) === 1) { + $title = $lines[0]; + } + + return ['title' => trim($title), 'author' => trim($author)]; + } + + /** + * Parse back page text (Tesseract fallback) + */ + private function parseBackText(string $text): array + { + $isbn = ''; + $publisher = ''; + $year = ''; + + if (preg_match('/ISBN[\s:\-]*([0-9\-X]{10,17})/i', $text, $matches)) { + $isbn = preg_replace('/[^0-9X]/', '', strtoupper($matches[1])); + } + if (preg_match('/(?:19|20)\d{2}/', $text, $matches)) { + $year = $matches[0]; + } + + return [ + 'title' => '', + 'author' => '', + 'isbn' => $isbn, + 'edition_house' => $publisher, + 'publication_year' => $year ? (int)$year : null, + ]; + } + + /** + * Decode base64 image data + */ + private function decodeBase64Image(string $base64): ?string + { + if (preg_match('/^data:image\/\w+;base64,/', $base64)) { + $base64 = preg_replace('/^data:image\/\w+;base64,/', '', $base64); + } + + $decoded = base64_decode($base64, true); + if ($decoded === false) { + return null; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->buffer($decoded); + $allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + + if (!in_array($mimeType, $allowedTypes, true)) { + return null; + } + + return $decoded; + } + + /** + * Search books by title and/or author + */ + private function searchBooks(string $title, string $author): array + { + if (empty($title) && empty($author)) { + return []; + } + + $db = Database::getConnection(); + $conditions = []; + $bindings = []; + + if (!empty($title)) { + $conditions[] = "title ILIKE :title"; + $bindings['title'] = '%' . $title . '%'; + } + if (!empty($author)) { + $conditions[] = "author ILIKE :author"; + $bindings['author'] = '%' . $author . '%'; + } + + $whereClause = implode(' OR ', $conditions); + $stmt = $db->prepare("SELECT id, title, author, isbn, genre, language FROM books WHERE {$whereClause} LIMIT 10"); + $stmt->execute($bindings); + + return $stmt->fetchAll(); + } + + /** + * Search books by ISBN + */ + private function searchByIsbn(string $isbn): array + { + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id, title, author, isbn, genre, language FROM books WHERE isbn = :isbn LIMIT 5"); + $stmt->execute(['isbn' => $isbn]); + return $stmt->fetchAll(); + } +} diff --git a/backend/app/Middleware/UserMiddleware.php b/backend/app/Middleware/UserMiddleware.php new file mode 100644 index 0000000..70cc2ab --- /dev/null +++ b/backend/app/Middleware/UserMiddleware.php @@ -0,0 +1,31 @@ +handle(); + + if ($result === false) { + return false; + } + + $user = $_REQUEST['auth_user'] ?? null; + $role = $user['role'] ?? ''; + + if (!in_array($role, ['admin', 'user'], true)) { + http_response_code(403); + echo json_encode(['error' => 'Access denied. This feature requires user or admin privileges.']); + return false; + } + + return true; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 8dc94c2..d6f9944 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -13,9 +13,13 @@ use App\Controllers\UsersController; use App\Controllers\GenresController; use App\Controllers\WritersController; +use App\Controllers\PublishersController; +use App\Controllers\LocationsController; +use App\Controllers\ScanController; use App\Controllers\BackupController; use App\Middleware\AuthMiddleware; use App\Middleware\AdminMiddleware; +use App\Middleware\UserMiddleware; // ===== Public Routes ===== @@ -39,6 +43,10 @@ $router->post('/api/books/{id}/toggle-read', [BooksController::class, 'toggleRead'], [AuthMiddleware::class]); $router->post('/api/books/isbn-lookup', [BooksController::class, 'isbnLookup'], [AuthMiddleware::class]); +// Book Scanner (camera OCR) - Admin and User only +$router->post('/api/scan/cover', [ScanController::class, 'scanCover'], [UserMiddleware::class]); +$router->post('/api/scan/back', [ScanController::class, 'scanBack'], [UserMiddleware::class]); + // Writers CRUD (read: any auth user, write: admin only) $router->get('/api/writers', [WritersController::class, 'index'], [AuthMiddleware::class]); $router->get('/api/writers/{id}', [WritersController::class, 'show'], [AuthMiddleware::class]); @@ -52,6 +60,29 @@ $router->put('/api/genres/{id}', [GenresController::class, 'update'], [AdminMiddleware::class]); $router->delete('/api/genres/{id}', [GenresController::class, 'destroy'], [AdminMiddleware::class]); +// Publishers CRUD (read: any auth user, write: admin only) +$router->get('/api/publishers', [PublishersController::class, 'index'], [AuthMiddleware::class]); +$router->get('/api/publishers/{id}', [PublishersController::class, 'show'], [AuthMiddleware::class]); +$router->post('/api/publishers', [PublishersController::class, 'store'], [AdminMiddleware::class]); +$router->put('/api/publishers/{id}', [PublishersController::class, 'update'], [AdminMiddleware::class]); +$router->delete('/api/publishers/{id}', [PublishersController::class, 'destroy'], [AdminMiddleware::class]); + +// Locations (Addresses > Rooms > Shelves) +$router->get('/api/locations/tree', [LocationsController::class, 'tree'], [AuthMiddleware::class]); +$router->get('/api/locations', [LocationsController::class, 'index'], [AuthMiddleware::class]); +$router->get('/api/locations/{id}', [LocationsController::class, 'show'], [AuthMiddleware::class]); +$router->post('/api/locations', [LocationsController::class, 'store'], [AdminMiddleware::class]); +$router->put('/api/locations/{id}', [LocationsController::class, 'update'], [AdminMiddleware::class]); +$router->delete('/api/locations/{id}', [LocationsController::class, 'destroy'], [AdminMiddleware::class]); +$router->get('/api/locations/{id}/rooms', [LocationsController::class, 'listRooms'], [AuthMiddleware::class]); +$router->post('/api/rooms', [LocationsController::class, 'storeRoom'], [AdminMiddleware::class]); +$router->put('/api/rooms/{id}', [LocationsController::class, 'updateRoom'], [AdminMiddleware::class]); +$router->delete('/api/rooms/{id}', [LocationsController::class, 'destroyRoom'], [AdminMiddleware::class]); +$router->get('/api/rooms/{id}/shelves', [LocationsController::class, 'listShelves'], [AuthMiddleware::class]); +$router->post('/api/shelves', [LocationsController::class, 'storeShelf'], [AdminMiddleware::class]); +$router->put('/api/shelves/{id}', [LocationsController::class, 'updateShelf'], [AdminMiddleware::class]); +$router->delete('/api/shelves/{id}', [LocationsController::class, 'destroyShelf'], [AdminMiddleware::class]); + // Lending $router->get('/api/lending', [LendingController::class, 'index'], [AuthMiddleware::class]); $router->post('/api/lending', [LendingController::class, 'store'], [AuthMiddleware::class]); diff --git a/docker-compose.yml b/docker-compose.yml index 6a2a8d6..899a369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,8 @@ services: DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.0-flash} ports: - "${BACKEND_PORT:-8080}:80" volumes: diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index dbcbc4a..be5fce2 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -5,6 +5,8 @@ RUN apt-get update && apt-get install -y \ git \ curl \ libpng-dev \ + libjpeg62-turbo-dev \ + libwebp-dev \ libonig-dev \ libxml2-dev \ libpq-dev \ @@ -12,6 +14,11 @@ RUN apt-get update && apt-get install -y \ zip \ unzip \ libicu-dev \ + tesseract-ocr \ + tesseract-ocr-ara \ + tesseract-ocr-fra \ + tesseract-ocr-eng \ + && docker-php-ext-configure gd --with-jpeg --with-webp \ && docker-php-ext-configure intl \ && docker-php-ext-install pdo pdo_pgsql pgsql mbstring exif pcntl bcmath gd zip intl \ && apt-get clean && rm -rf /var/lib/apt/lists/* @@ -19,6 +26,14 @@ RUN apt-get update && apt-get install -y \ # Enable Apache mod_rewrite RUN a2enmod rewrite headers +# Increase PHP limits for image uploads (base64 encoded images are large) +RUN echo 'post_max_size = 20M' > /usr/local/etc/php/conf.d/uploads.ini && \ + echo 'upload_max_filesize = 20M' >> /usr/local/etc/php/conf.d/uploads.ini && \ + echo 'memory_limit = 256M' >> /usr/local/etc/php/conf.d/uploads.ini && \ + echo 'max_execution_time = 60' >> /usr/local/etc/php/conf.d/uploads.ini && \ + echo 'display_errors = Off' >> /usr/local/etc/php/conf.d/uploads.ini && \ + echo 'log_errors = On' >> /usr/local/etc/php/conf.d/uploads.ini + # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer diff --git a/docker/init.sql b/docker/init.sql index 139afc9..e7a3dcc 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -17,6 +17,91 @@ CREATE TABLE users ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- Genres reference table +CREATE TABLE genres ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + name_ar VARCHAR(100), + name_fr VARCHAR(100) +); + +-- Writers table +CREATE TABLE writers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + name_ar VARCHAR(255), + name_fr VARCHAR(255), + nationality VARCHAR(100), + birth_year INTEGER, + death_year INTEGER, + biography TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_writers_name ON writers(name); +CREATE INDEX idx_writers_name_ar ON writers(name_ar); + +-- Publishers table +CREATE TABLE publishers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + name_ar VARCHAR(255), + name_fr VARCHAR(255), + address TEXT, + city VARCHAR(100), + country VARCHAR(100), + phone VARCHAR(50), + email VARCHAR(255), + website VARCHAR(255), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_publishers_name ON publishers(name); + +-- Library Addresses (multiple physical locations) +CREATE TABLE addresses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + street VARCHAR(255), + city VARCHAR(100), + state_province VARCHAR(100), + postal_code VARCHAR(20), + country VARCHAR(100), + is_primary BOOLEAN DEFAULT FALSE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Rooms within addresses +CREATE TABLE rooms ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + address_id UUID NOT NULL REFERENCES addresses(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + floor VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_rooms_address ON rooms(address_id); + +-- Shelves within rooms +CREATE TABLE shelves ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + capacity INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_shelves_room ON shelves(room_id); + -- Books table CREATE TABLE books ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -27,6 +112,8 @@ CREATE TABLE books ( language VARCHAR(50) DEFAULT 'arabic' CHECK (language IN ('arabic', 'english', 'french', 'other')), location_room VARCHAR(100), location_shelf VARCHAR(100), + shelf_id UUID REFERENCES shelves(id) ON DELETE SET NULL, + publisher_id UUID REFERENCES publishers(id) ON DELETE SET NULL, read_status BOOLEAN DEFAULT FALSE, isbn VARCHAR(20), edition_house VARCHAR(255), @@ -56,31 +143,6 @@ CREATE TABLE lending_records ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Genres reference table -CREATE TABLE genres ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, - name_ar VARCHAR(100), - name_fr VARCHAR(100) -); - --- Writers table -CREATE TABLE writers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(255) NOT NULL, - name_ar VARCHAR(255), - name_fr VARCHAR(255), - nationality VARCHAR(100), - birth_year INTEGER, - death_year INTEGER, - biography TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - -CREATE UNIQUE INDEX idx_writers_name ON writers(name); -CREATE INDEX idx_writers_name_ar ON writers(name_ar); - -- Audit log table CREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -101,6 +163,8 @@ CREATE INDEX idx_books_publication_year ON books(publication_year); CREATE INDEX idx_books_read_status ON books(read_status); CREATE INDEX idx_books_isbn ON books(isbn); CREATE INDEX idx_books_owner ON books(owner_id); +CREATE INDEX idx_books_shelf ON books(shelf_id); +CREATE INDEX idx_books_publisher ON books(publisher_id); CREATE INDEX idx_lending_book ON lending_records(book_id); CREATE INDEX idx_lending_status ON lending_records(status); CREATE INDEX idx_lending_due_date ON lending_records(due_date); diff --git a/docker/nginx.conf b/docker/nginx.conf index 069f802..f09625f 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -8,10 +8,10 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Permissions-Policy "geolocation=()" always; add_header X-Permitted-Cross-Domain-Policies "none" always; # Note: 'unsafe-eval' required by vue-i18n message compiler at runtime - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:8080 https://localhost:8080 http://127.0.0.1:8080;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:8080 http://192.168.1.12:8080;" always; # Hide nginx version server_tokens off; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0dbfa66..acbd283 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,13 +7,15 @@
Bookoholik - {{ t('app_name') }} + - -
+
- + - + + +
- -
- {{ t('nav.books') }} - {{ t('nav.writers') }} - {{ t('nav.genres') }} - {{ t('nav.lending') }} - {{ t('nav.reports') }} - {{ t('nav.users') }} - {{ t('nav.backup') }} - ⚙️ {{ t('nav.settings') }} + +
+ 📚 {{ t('nav.books') }} + ✍️ {{ t('nav.writers') }} + 📖 {{ t('nav.genres') }} + 🏢 {{ t('nav.publishers') }} + 📍 {{ t('nav.locations') }} + 🤝 {{ t('nav.lending') }} + 📊 {{ t('nav.reports') }} + 👥 {{ t('nav.users') }} + 💾 {{ t('nav.backup') }} +
+ ⚙️ {{ t('nav.settings') }} + +
@@ -83,6 +97,7 @@ const authStore = useAuthStore() const toastStore = useToastStore() const currentLocale = ref(localStorage.getItem('app_locale') || 'en') +const mobileMenuOpen = ref(false) const isAuthenticated = computed(() => authStore.isAuthenticated) const user = computed(() => authStore.user) const isAdmin = computed(() => authStore.user?.role === 'admin') @@ -93,6 +108,7 @@ function switchLanguage() { } function logout() { + mobileMenuOpen.value = false authStore.logout() router.push('/login') } diff --git a/frontend/src/i18n/ar.js b/frontend/src/i18n/ar.js index cd641e2..dabbae5 100644 --- a/frontend/src/i18n/ar.js +++ b/frontend/src/i18n/ar.js @@ -26,6 +26,8 @@ export default { books: 'الكتب', writers: 'الكتّاب', genres: 'الأنواع', + publishers: 'الناشرون', + locations: 'المواقع', lending: 'الإعارة', reports: 'التقارير', users: 'المستخدمون', @@ -318,4 +320,36 @@ export default { role: 'الدور', member_since: 'عضو منذ', }, + + // Book Scanner + scan: { + title: 'مسح كتاب', + scan_book: 'مسح كتاب', + front_cover: 'الغلاف الأمامي', + back_page: 'الصفحة الخلفية', + cover_hint: 'للحصول على العنوان والمؤلف', + back_hint: 'للحصول على ISBN والناشر', + capture_cover: 'التقاط الغلاف الأمامي', + capture_back: 'التقاط الصفحة الخلفية', + open_camera: 'فتح الكاميرا', + take_photo: 'التقاط صورة', + live_camera: 'كاميرا مباشرة', + upload_photo: 'رفع من المعرض', + analyze: 'تحليل', + analyzing: 'جاري التحليل...', + retake: 'إعادة الالتقاط', + results: 'النتائج', + extracted_text: 'النص المستخرج:', + book_found: 'الكتاب موجود في مكتبتك!', + view_book: 'عرض', + not_found: 'الكتاب غير موجود في مكتبتك', + not_found_hint: 'هل تريد إضافة هذا الكتاب إلى مجموعتك؟', + add_book: 'إضافة هذا الكتاب', + search_library: 'البحث في المكتبة', + correct_hint: 'صحح النص أدناه إذا لزم الأمر، ثم ابحث:', + enter_something: 'يرجى إدخال عنوان أو مؤلف للبحث.', + raw_ocr: 'النص الخام من OCR', + camera_error: 'تعذر الوصول إلى الكاميرا. يرجى السماح بالأذونات أو استخدام رفع الملفات.', + analyze_failed: 'فشل تحليل الصورة. جرب صورة أوضح.', + }, } diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index f268613..80119e7 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -26,6 +26,8 @@ export default { books: 'Books', writers: 'Writers', genres: 'Genres', + publishers: 'Publishers', + locations: 'Locations', lending: 'Lending', reports: 'Reports', users: 'Users', @@ -318,4 +320,92 @@ export default { role: 'Role', member_since: 'Member Since', }, + + // Book Scanner + scan: { + title: 'Scan Book', + scan_book: 'Scan Book', + front_cover: 'Front Cover', + back_page: 'Back Page', + cover_hint: 'Get title & author', + back_hint: 'Get ISBN & publisher', + capture_cover: 'Capture front cover', + capture_back: 'Capture back page', + open_camera: 'Open Camera', + take_photo: 'Take Photo', + live_camera: 'Live Camera', + upload_photo: 'Upload from Gallery', + analyze: 'Analyze', + analyzing: 'Analyzing...', + retake: 'Retake', + results: 'Results', + extracted_text: 'Extracted text:', + book_found: 'Book found in your library!', + view_book: 'View', + not_found: 'Book not found in your library', + not_found_hint: 'Would you like to add this book to your collection?', + add_book: 'Add this Book', + search_library: 'Search in Library', + correct_hint: 'Correct the text below if needed, then search:', + enter_something: 'Please enter a title or author to search.', + raw_ocr: 'Raw OCR output (debug)', + camera_error: 'Could not access camera. Please allow camera permissions or use file upload.', + analyze_failed: 'Failed to analyze image. Try a clearer photo.', + }, + + // Publishers + publishers: { + title: 'Publishers', + add: 'Add Publisher', + edit: 'Edit Publisher', + search_placeholder: '🔍 Search publishers...', + no_publishers: 'No publishers found.', + name_en: 'Name (English)', + name_ar: 'Name (Arabic)', + name_fr: 'Name (French)', + address: 'Address', + city: 'City', + country: 'Country', + phone: 'Phone', + email: 'Email', + website: 'Website', + created: 'Publisher created!', + updated: 'Publisher updated!', + deleted: 'Publisher deleted.', + save_failed: 'Failed to save publisher.', + delete_confirm: 'Delete publisher "{name}"?', + delete_failed: 'Failed to delete publisher.', + }, + + // Locations + locations: { + title: 'Library Locations', + add_address: 'Add Location', + no_addresses: 'No locations configured yet. Add your first library location.', + primary: 'Primary', + address: 'Address', + name: 'Name', + name_placeholder: 'e.g. Home, Office, Summer House', + street: 'Street', + city: 'City', + state: 'State/Province', + postal_code: 'Postal Code', + country: 'Country', + set_primary: 'Set as primary location', + rooms: 'rooms', + shelves: 'shelves', + show_rooms: 'Show rooms & shelves', + add_room: 'Add Room', + room: 'Room', + floor: 'Floor', + description: 'Description', + add_shelf: 'Add Shelf', + shelf: 'Shelf', + capacity: 'Capacity', + saved: 'Saved successfully!', + deleted: 'Deleted.', + delete_confirm: 'Delete location "{name}" and all its rooms/shelves?', + delete_room_confirm: 'Delete this room and all its shelves?', + delete_shelf_confirm: 'Delete this shelf?', + }, } diff --git a/frontend/src/i18n/fr.js b/frontend/src/i18n/fr.js index c2becfe..44ae4b5 100644 --- a/frontend/src/i18n/fr.js +++ b/frontend/src/i18n/fr.js @@ -26,6 +26,8 @@ export default { books: 'Livres', writers: 'Écrivains', genres: 'Genres', + publishers: 'Éditeurs', + locations: 'Emplacements', lending: 'Prêts', reports: 'Rapports', users: 'Utilisateurs', @@ -318,4 +320,36 @@ export default { role: 'Rôle', member_since: 'Membre depuis', }, + + // Book Scanner + scan: { + title: 'Scanner un livre', + scan_book: 'Scanner', + front_cover: 'Couverture', + back_page: 'Dernière page', + cover_hint: 'Titre et auteur', + back_hint: 'ISBN et éditeur', + capture_cover: 'Capturer la couverture', + capture_back: 'Capturer la dernière page', + open_camera: 'Ouvrir la caméra', + take_photo: 'Prendre une photo', + live_camera: 'Caméra en direct', + upload_photo: 'Depuis la galerie', + analyze: 'Analyser', + analyzing: 'Analyse en cours...', + retake: 'Reprendre', + results: 'Résultats', + extracted_text: 'Texte extrait :', + book_found: 'Livre trouvé dans votre bibliothèque !', + view_book: 'Voir', + not_found: 'Livre non trouvé dans votre bibliothèque', + not_found_hint: 'Souhaitez-vous ajouter ce livre à votre collection ?', + add_book: 'Ajouter ce livre', + search_library: 'Rechercher dans la bibliothèque', + correct_hint: 'Corrigez le texte ci-dessous si nécessaire, puis recherchez :', + enter_something: 'Veuillez entrer un titre ou un auteur pour rechercher.', + raw_ocr: 'Texte brut OCR (debug)', + camera_error: "Impossible d'accéder à la caméra. Veuillez autoriser les permissions ou utiliser le téléchargement.", + analyze_failed: "Échec de l'analyse. Essayez une photo plus nette.", + }, } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index b89c9fd..b6fefb0 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -26,6 +26,12 @@ const routes = [ component: () => import('../views/BookFormView.vue'), meta: { requiresAuth: true } }, + { + path: '/books/scan', + name: 'ScanBook', + component: () => import('../views/ScanBookView.vue'), + meta: { requiresAuth: true, requiresUser: true } + }, { path: '/books/:id', name: 'BookDetail', @@ -50,6 +56,18 @@ const routes = [ component: () => import('../views/GenresView.vue'), meta: { requiresAuth: true } }, + { + path: '/publishers', + name: 'Publishers', + component: () => import('../views/PublishersView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/locations', + name: 'Locations', + component: () => import('../views/LocationsView.vue'), + meta: { requiresAuth: true } + }, { path: '/lending', name: 'Lending', @@ -97,6 +115,8 @@ router.beforeEach((to, from, next) => { next('/') } else if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') { next('/') + } else if (to.meta.requiresUser && !['admin', 'user'].includes(authStore.user?.role)) { + next('/') } else { next() } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 5ab2adc..0b05ec8 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -32,7 +32,9 @@ function forceLogout() { api.interceptors.request.use(config => { const token = localStorage.getItem('auth_token') if (token) { - if (isTokenExpired(token)) { + // Skip token expiry check for login/register endpoints + const isAuthEndpoint = config.url?.includes('/auth/login') || config.url?.includes('/auth/register') + if (!isAuthEndpoint && isTokenExpired(token)) { forceLogout() return Promise.reject(new Error('Token expired')) } diff --git a/frontend/src/views/BookFormView.vue b/frontend/src/views/BookFormView.vue index 22afb00..8aba1ec 100644 --- a/frontend/src/views/BookFormView.vue +++ b/frontend/src/views/BookFormView.vue @@ -259,6 +259,14 @@ onMounted(() => { loadGenres() loadWriters() loadBook() + + // Pre-fill from query params (from scanner) + const query = route.query + if (query.title) form.title = query.title + if (query.author) form.author = query.author + if (query.isbn) form.isbn = query.isbn + if (query.edition_house) form.edition_house = query.edition_house + if (query.publication_year) form.publication_year = parseInt(query.publication_year) }) diff --git a/frontend/src/views/BooksView.vue b/frontend/src/views/BooksView.vue index 740bca2..4d4930d 100644 --- a/frontend/src/views/BooksView.vue +++ b/frontend/src/views/BooksView.vue @@ -3,9 +3,14 @@

📚 {{ t('books.title') }}

- - ➕ {{ t('books.add_book') }} - +
+ + 📷 {{ t('scan.scan_book') }} + + + ➕ {{ t('books.add_book') }} + +
@@ -126,8 +131,11 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import api from '../services/api' +import { useAuthStore } from '../store/auth' const { t } = useI18n() +const authStore = useAuthStore() +const canScan = computed(() => ['admin', 'user'].includes(authStore.user?.role)) const books = ref([]) const genres = ref([]) const loading = ref(false) @@ -209,4 +217,8 @@ onMounted(() => { .btn-primary { @apply px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors text-sm; } + +.btn-secondary { + @apply px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm; +} diff --git a/frontend/src/views/LocationsView.vue b/frontend/src/views/LocationsView.vue new file mode 100644 index 0000000..c4a5cc9 --- /dev/null +++ b/frontend/src/views/LocationsView.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/frontend/src/views/PublishersView.vue b/frontend/src/views/PublishersView.vue new file mode 100644 index 0000000..6de121f --- /dev/null +++ b/frontend/src/views/PublishersView.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/ScanBookView.vue b/frontend/src/views/ScanBookView.vue new file mode 100644 index 0000000..f7b95b6 --- /dev/null +++ b/frontend/src/views/ScanBookView.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/sonar-project.properties b/sonar-project.properties index 7d7ae6a..762ce3a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,6 @@ - -sonar.projectKey=XKlibure_home-library -sonar.organization=xklibure -sonar.projectVersion=0.1.0 +sonar.projectKey=bookoholik +sonar.projectName=Bookoholik +sonar.projectVersion=1.0.0 sonar.sources=backend/app,frontend/src sonar.sourceEncoding=UTF-8