Compare commits

...

39 Commits

Author SHA1 Message Date
Aiirondev_dev b3aeb289ef required thingis removed 2026-06-12 19:59:05 +02:00
Aiirondev_dev a6b90a6a9d fix of a spelling mistake 2026-06-12 19:50:01 +02:00
Aiirondev_dev 0a5d7dedea new implementation of the mail module relevant fuktions, in addition added thje funktion to add a title to the Terminplan 2026-06-12 19:43:37 +02:00
Aiirondev_dev 74e11b5370 Renamed the Filter Option 3 from Thema into Schlagwort for a better comprehension 2026-06-12 18:34:07 +02:00
Aiirondev_dev c0d6ad7b72 simpicity changes for the varible hardening 2026-06-12 00:27:04 +02:00
Aiirondev_dev 7da7152332 Fixes applied to the Barcode scanning 2026-06-12 00:14:20 +02:00
Aiirondev_dev 3c9ab099ba Slight fixes for the Scanning 2026-06-11 23:58:11 +02:00
Aiirondev_dev a81a8775a5 correction oif a tabulation error 2026-06-11 22:08:53 +02:00
Aiirondev_dev 1633118d7f center fix for the edit Modal 2026-06-10 12:48:14 +02:00
Aiirondev_dev a4b900c986 Fixes regarding ht eupload changes 2026-06-10 12:39:08 +02:00
Aiirondev_dev a06393a915 New implementiation of the Code Scannning mecanism 2026-06-10 12:26:25 +02:00
Aiirondev_dev 1b9bfe1b14 Fix of the edeting funktion in the Library Module 2026-06-10 11:24:33 +02:00
Aiirondev_dev 3cdd1552d9 Fix of a library funktion 2026-06-10 11:08:43 +02:00
Aiirondev_dev 80a206f524 Finisched the removal of the Author 2026-06-07 23:16:05 +02:00
Aiirondev_dev 3217da8b15 Improved ISBN Funktionality 2026-06-07 22:57:32 +02:00
Aiirondev_dev 22bc7c9c3c upload barcode implementation 2026-06-07 22:28:04 +02:00
Aiirondev_dev f156cc178a library author removing 2026-06-07 22:21:01 +02:00
Aiirondev_dev 116261d334 Slight fixes of thew Code scanning and Items processing 2026-06-07 12:42:27 +02:00
Aiirondev_dev 042afc19e2 slight changes to the Bar code scanner 2026-06-07 12:33:13 +02:00
Aiirondev_dev 28d049b52a smal changes fixes for the library Code Scanner 2026-06-07 00:14:57 +02:00
Aiirondev_dev 8e478aeba4 slight fixes 2026-06-07 00:03:21 +02:00
Aiirondev_dev 1b38d1f0c8 changed the wrapper of the new code scanning library 2026-06-06 23:52:19 +02:00
Aiirondev_dev e7e24b3fae further changes to the new Barcode system 2026-06-06 23:41:37 +02:00
Aiirondev_dev fc80857fbe minor changes to the Inpout scanning 2026-06-06 23:31:50 +02:00
Aiirondev_dev f763ad064d Changes from the deprecated qrcode scanning tool, to a more modern option in the library 2026-06-06 22:46:50 +02:00
Aiirondev_dev bc94ffdc18 further fixes 2026-06-06 17:17:15 +02:00
Aiirondev_dev 76e9670cee implementation of a new Framework for the Barcode scanning via Quagga2 -> only test implementation 2026-06-06 16:53:11 +02:00
Aiirondev_dev 577c6c4bed Removing more of the broken funktionaliti 2026-06-06 15:39:51 +02:00
Aiirondev_dev 3aa19b10d1 Reverted Faulti changes that have proven not worth implementing 2026-06-06 15:03:43 +02:00
Aiirondev_dev ff742d018e removed obsolet file 2026-06-01 14:34:05 +02:00
Aiirondev_dev 6fa40f26b0 Generall fixes, to manage the unused overhead 2026-06-01 13:23:52 +02:00
Aiirondev_dev 777fc81064 fix for the latest Tag 2026-06-01 13:20:10 +02:00
Aiirondev_dev cf5e38e319 removed development release version 2026-06-01 13:15:54 +02:00
Aiirondev_dev b76941d5fe Fix in the release versioning 2026-06-01 13:07:52 +02:00
Aiirondev_dev f46f2674b0 Library style adjustments for mobile 2026-06-01 12:51:28 +02:00
github-actions[bot] 8ebbdcfd54 chore: bump version to v0.8.31.1 2026-06-01 10:35:56 +00:00
Aiirondev_dev 92b5235035 feat: Improve mobile video playback for barcode scanning with autoplay and mute settings 2026-06-01 12:32:42 +02:00
github-actions[bot] 4bda2e044b chore: bump version to v0.8.30 2026-06-01 10:23:51 +00:00
Aiirondev_dev b83b6c0ba2 feat: Enhance barcode scanner with improved mobile compatibility and error handling 2026-06-01 12:22:54 +02:00
19 changed files with 1346 additions and 1697 deletions
+14 -89
View File
@@ -15,15 +15,6 @@ on:
- patch - patch
- minor - minor
- major - major
- development
push_dev:
description: "If true, push the :dev image to GHCR for development releases"
required: false
default: "false"
type: choice
options:
- "true"
- "false"
permissions: permissions:
contents: write contents: write
@@ -48,13 +39,12 @@ jobs:
EVENT_NAME: ${{ github.event_name }} EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }} BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
run: | run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME" TAG="$REF_NAME"
else else
# Fetch latest release tag via GitHub API (fall back to v3.0.0) # Fetch latest release tag via GitHub API (fall back to v0.8.31)
latest_tag="v3.0.0" latest_tag="v0.8.31"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [ -n "$tag_name" ]; then if [ -n "$tag_name" ]; then
@@ -67,7 +57,7 @@ jobs:
minor=${BASH_REMATCH[2]} minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]} patch=${BASH_REMATCH[3]}
else else
major=3; minor=0; patch=0 major=0; minor=8; patch=31
fi fi
# Bump strategy: major / minor / patch # Bump strategy: major / minor / patch
@@ -78,30 +68,28 @@ jobs:
else else
patch=$((patch + 1)) patch=$((patch + 1))
fi fi
if [ "${BUMP_TYPE:-}" = "development" ]; then # Zusammenbau des Tags für den manuellen Run
TAG="v${major}.${minor}.${patch}-dev" TAG="v${major}.${minor}.${patch}"
else
TAG="v${major}.${minor}.${patch}"
fi
fi fi
# Validierung des erzeugten oder übergebenen Tags
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)" echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
exit 1 exit 1
fi fi
git fetch --tags --force git fetch --tags --force
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)" LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
if [ -z "$LATEST_TAG" ]; then if [ -z "$LATEST_TAG" ]; then
LATEST_TAG="v3.0.0" LATEST_TAG="v0.8.31"
fi fi
TAG_MAJOR="${TAG#v}" TAG_MAJOR="${TAG#v}"
TAG_MAJOR="${TAG_MAJOR%%.*}" TAG_MAJOR="${TAG_MAJOR%%.*}"
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')" LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
if [ -z "$LATEST_MAJOR" ]; then if [ -z "$LATEST_MAJOR" ]; then
LATEST_MAJOR="3" LATEST_MAJOR="0"
fi fi
# If not explicitly bumping major, disallow changing major version # If not explicitly bumping major, disallow changing major version
@@ -123,33 +111,6 @@ jobs:
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}" IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT" echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
if [ "${BUMP_TYPE:-}" = "development" ]; then
echo "is_development=true" >> "$GITHUB_OUTPUT"
else
echo "is_development=false" >> "$GITHUB_OUTPUT"
fi
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
echo "push_dev=true" >> "$GITHUB_OUTPUT"
else
echo "push_dev=false" >> "$GITHUB_OUTPUT"
fi
- name: Update .release-version file
run: |
echo "${{ steps.meta.outputs.tag }}" > .release-version
cat .release-version
- name: Create and push tag for manual releases
if: github.event_name == 'workflow_dispatch'
run: |
TAG="${{ steps.meta.outputs.tag }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .release-version
git commit -m "chore: bump version to $TAG" || true
git tag "$TAG"
git push origin "$TAG"
git push origin HEAD:${{ github.ref_name }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -162,26 +123,16 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push release image - name: Build and push release image
if: steps.meta.outputs.is_development != 'true'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
tags: | tags: |
${{ steps.meta.outputs.image }} ${{ steps.meta.outputs.image }}
ghcr.io/aiirondev/legendary-octo-garbanzo:latest ghcr.io/aiirondev/legendary-octo-garbanzo:latest
- name: Build and push development image (:dev)
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
- name: Build local image tar for offline deploy - name: Build local image tar for offline deploy
run: | run: |
set -euo pipefail set -euo pipefail
@@ -204,30 +155,17 @@ jobs:
docker build -t "$IMG" . docker build -t "$IMG" .
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz" docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
- name: Commit .release-version for tag pushes
if: github.event_name == 'push'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet .release-version; then
git add .release-version
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
git push origin HEAD:${{ github.ref_name }}
fi
- name: Create release-only docker bundle - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
cat > release-bundle/docker-compose.yml <<EOF cat > release-bundle/docker-compose.yml <<EOF
services: services:
app: app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}} image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
container_name: inventarsystem-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000" - "\${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on: depends_on:
- mongodb - mongodb
- redis - redis
@@ -300,17 +238,6 @@ jobs:
fi fi
done done
cat > release-bundle/DEVELOPMENT.md <<EOF
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
This prerelease is intentionally separate from normal releases and will not be used by default.
To install this prerelease on a target host run:
./update.sh ${{ steps.meta.outputs.tag }}
EOF
# Make any shipped scripts executable # Make any shipped scripts executable
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle . tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
@@ -319,10 +246,8 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.meta.outputs.tag }} tag_name: ${{ steps.meta.outputs.tag }}
prerelease: ${{ steps.meta.outputs.is_development }}
files: | files: |
inventarsystem-docker-bundle.tar.gz inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
inventarsystem-image-dev.tar.gz
fail_on_unmatched_files: false fail_on_unmatched_files: false
generate_release_notes: true generate_release_notes: true
+1 -1
View File
@@ -1 +1 @@
v0.8.29 v0.8.31.1
+231 -177
View File
@@ -1268,7 +1268,8 @@ def inject_version():
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'), 'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'), 'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
'library_module_enabled': cfg.MODULES.is_enabled('library'), 'library_module_enabled': cfg.MODULES.is_enabled('library'),
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'), 'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),#
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
'is_admin': is_admin, 'is_admin': is_admin,
'unread_notification_count': unread_notification_count, 'unread_notification_count': unread_notification_count,
'current_permissions': current_permissions, 'current_permissions': current_permissions,
@@ -2885,6 +2886,7 @@ def home():
username=session['username'], username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
open_item=request.args.get('open_item') open_item=request.args.get('open_item')
@@ -2927,6 +2929,7 @@ def home_admin():
username=session['username'], username=session['username'],
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS, student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(), school_info=_get_school_info_for_export(),
@@ -2947,6 +2950,7 @@ def tutorial_page():
is_admin=us.check_admin(session['username']), is_admin=us.check_admin(session['username']),
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS, student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
) )
@@ -3484,66 +3488,6 @@ def api_library_scan_action():
client.close() client.close()
@app.route('/api/scan_upload', methods=['POST'])
def api_scan_upload():
"""
Server-side barcode/QR decoding endpoint.
Accepts image upload and returns detected barcodes (fallback for client-side scanner).
"""
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
try:
from pyzbar.pyzbar import decode
from PIL import Image
import io
except ImportError:
return jsonify({'ok': False, 'message': 'pyzbar library not installed on server.'}), 500
if 'image' not in request.files:
return jsonify({'ok': False, 'message': 'Keine Bilddatei bereitgestellt.'}), 400
file = request.files['image']
if file.filename == '':
return jsonify({'ok': False, 'message': 'Datei ist leer.'}), 400
try:
# Decode image
image = Image.open(io.BytesIO(file.read()))
image = image.convert('RGB')
# Detect barcodes using pyzbar
results = decode(image)
if not results:
return jsonify({'ok': False, 'message': 'Kein Barcode im Bild erkannt.'}), 400
# Return all detected barcodes, sorted by confidence (quality)
barcodes = [
{
'type': result.type,
'value': result.data.decode('utf-8'),
'rect': {
'x': result.rect.left,
'y': result.rect.top,
'width': result.rect.width,
'height': result.rect.height,
}
}
for result in results
]
return jsonify({
'ok': True,
'barcodes': barcodes,
'message': f'{len(barcodes)} Barcode(s) erkannt.'
}), 200
except Exception as e:
app.logger.error(f"Error decoding barcode image: {e}")
return jsonify({'ok': False, 'message': f'Fehler beim Dekodieren: {str(e)}'}), 500
@app.route('/api/item_detail/<item_id>') @app.route('/api/item_detail/<item_id>')
def api_item_detail(item_id): def api_item_detail(item_id):
""" """
@@ -5267,55 +5211,87 @@ def upload_item():
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
# Check if base code is unique for single-item uploads # -------------------------------------------------------------------
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]): # 1. PARSE AND UNIFY CODES
existing_item = it._get_items_collection().find_one({"code_4": code_4[0]}) # -------------------------------------------------------------------
if existing_item: # Assuming `code_4` might come in as a single string or a list depending on your form setup
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.' primary_code_raw = request.form.get('code_4', '').strip()
if is_mobile: individual_codes_raw = request.form.get('individual_codes', '').strip()
return jsonify({
'success': False, all_item_codes = []
'message': error_msg,
'existing_item_id': str(existing_item['_id']), if primary_code_raw:
'redirect_to_item': True all_item_codes.append(primary_code_raw)
}), 400
if individual_codes_raw:
flash(error_msg, 'info') # Split textarea by newlines, clean whitespace, and ignore empty lines
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id']))) extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
# Add to master list, ensuring no duplicates within the submitted list itself
for c in extra_codes:
if c not in all_item_codes:
all_item_codes.append(c)
# -------------------------------------------------------------------
# 2. OVERRIDE ITEM COUNT
# -------------------------------------------------------------------
# If the user scanned/generated codes, the amount of codes IS the item count.
# Otherwise, fallback to a manual item_count field if it exists.
if len(all_item_codes) > 0:
item_count = len(all_item_codes)
else:
try:
item_count = int(request.form.get('item_count', 1))
except ValueError:
item_count = 1
# -------------------------------------------------------------------
# 3. IMAGE VALIDATION
# -------------------------------------------------------------------
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
else: else:
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
# -------------------------------------------------------------------
# 4. UNIFIED DB UNIQUENESS VALIDATION
# -------------------------------------------------------------------
# Validate every code in our master list against the database
for code in all_item_codes:
if not it.is_code_unique(code):
# Special case: If they only uploaded ONE item, redirect them to that existing item
if item_count == 1:
existing_item = it._get_items_collection().find_one({"code_4": code})
if existing_item:
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile:
return jsonify({
'success': False,
'message': error_msg,
'existing_item_id': str(existing_item['_id']),
'redirect_to_item': True
}), 400
flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
# If multiple items are being uploaded, just throw a standard error
error_msg = f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet. Bitte überprüfen Sie Ihre Eingaben.'
if is_mobile: if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400 return jsonify({'success': False, 'message': error_msg}), 400
else: else:
flash(error_msg, 'error') flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint)) return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes # -------------------------------------------------------------------
if individual_codes: # 5. BATCH CODE GENERATOR (Remains mostly unchanged)
if len(individual_codes) > item_count: # -------------------------------------------------------------------
error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
if len(set(individual_codes)) != len(individual_codes):
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
for specific_code in individual_codes:
if not it.is_code_unique(specific_code):
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
def generate_unique_batch_code(base_code, position): def generate_unique_batch_code(base_code, position):
"""Generate a unique code for every item in a batch.""" """Generate a unique code for every item in a batch if no specific code is provided."""
if not base_code: if not base_code:
return None return None
@@ -8008,7 +7984,8 @@ def admin_borrowings():
'admin_borrowings.html', 'admin_borrowings.html',
entries=entries, entries=entries,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards') student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
) )
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------""" """-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
@@ -8071,6 +8048,7 @@ def admin_audit_dashboard():
audit_rows=audit_rows, audit_rows=audit_rows,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
) )
except Exception as exc: except Exception as exc:
app.logger.error(f"Error loading audit dashboard: {exc}") app.logger.error(f"Error loading audit dashboard: {exc}")
@@ -8886,6 +8864,7 @@ def library_item_invoices(item_id):
invoices=entries, invoices=entries,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
) )
except Exception as e: except Exception as e:
app.logger.error(f"Error loading invoice history for item {item_id}: {e}") app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
@@ -9436,17 +9415,137 @@ def search_word(word):
except Exception as e: except Exception as e:
return jsonify({"success": False, "response": str(e)}) return jsonify({"success": False, "response": str(e)})
def _fetch_from_google_books(clean_isbn):
"""Source 1: Google Books API (Free, No Key required for basic use)"""
try:
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
data = response.json()
if data.get('totalItems', 0) > 0 and data.get('items'):
book_info = data['items'][0].get('volumeInfo', {})
sale_info = data['items'][0].get('saleInfo', {})
price = None
retail_price = sale_info.get('retailPrice', {})
list_price = sale_info.get('listPrice', {})
if retail_price and 'amount' in retail_price:
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '')}"
elif list_price and 'amount' in list_price:
price = f"{list_price['amount']} {list_price.get('currencyCode', '')}"
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
if thumbnail:
thumbnail = thumbnail.replace('http:', 'https:')
return {
"title": book_info.get('title', 'Unknown Title'),
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
"publisher": book_info.get('publisher', 'Unknown Publisher'),
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
"description": book_info.get('description', 'Keine Beschreibung verfügbar'),
"pageCount": book_info.get('pageCount', 'Unknown'),
"price": price,
"thumbnail": thumbnail,
"source": "google-books"
}
except Exception as e:
print(f"Google Books error: {e}")
return None
def _fetch_from_lobid_germany(clean_isbn):
"""
Source 2: Lobid.org (hbz Network)
The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
"""
try:
url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
data = response.json()
if 'member' in data and len(data['member']) > 0:
book = data['member'][0]
# Extract Authors (Lobid uses a nested contribution array)
authors = []
for contrib in book.get('contribution', []):
if 'agent' in contrib and 'label' in contrib['agent']:
authors.append(contrib['agent']['label'])
# Extract Publishers
publishers = []
for pub in book.get('publication', []):
if 'publishedBy' in pub:
# sometimes publishedBy is a list, sometimes a string
pub_data = pub['publishedBy']
if isinstance(pub_data, list):
publishers.extend(pub_data)
else:
publishers.append(pub_data)
# Extract Date
pub_date = 'Unknown Date'
if book.get('publication'):
pub_date = book['publication'][0].get('startDate', 'Unknown Date')
# Extract Pages
pages = book.get('extent', ['Unknown'])[0] if 'extent' in book else 'Unknown'
return {
"title": book.get('title', 'Unknown Title'),
"authors": ', '.join(authors) if authors else 'Unknown Author',
"publisher": ', '.join(publishers) if publishers else 'Unknown Publisher',
"publishedDate": str(pub_date),
"description": 'Keine Beschreibung verfügbar (Schulbuch / Fachliteratur)',
"pageCount": pages,
"price": None,
"thumbnail": "", # Lobid rarely has covers, we will fallback to OpenLibrary cover below
"source": "lobid-germany"
}
except Exception as e:
print(f"Lobid error: {e}")
return None
def _fetch_from_open_library(clean_isbn):
"""Source 3: Open Library Search API (Free, No Key required)"""
try:
url = f"https://openlibrary.org/search.json?isbn={clean_isbn}"
response = requests.get(url, timeout=5)
if response.status_code != 200:
return None
data = response.json()
if data.get('numFound', 0) > 0 and data.get('docs'):
book_info = data['docs'][0]
return {
"title": book_info.get('title', 'Unknown Title'),
"authors": ', '.join(book_info.get('author_name', ['Unknown Author'])),
"publisher": ', '.join(book_info.get('publisher', ['Unknown Publisher'])),
"publishedDate": str(book_info.get('publish_date', ['Unknown Date'])[0]),
"description": 'Keine Beschreibung verfügbar',
"pageCount": book_info.get('number_of_pages_median', 'Unknown'),
"price": None,
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
"source": "openlibrary"
}
except Exception as e:
print(f"OpenLibrary Search error: {e}")
return None
@app.route('/fetch_book_info/<isbn>') @app.route('/fetch_book_info/<isbn>')
def fetch_book_info(isbn): def fetch_book_info(isbn):
""" """
API endpoint to fetch book information by ISBN using Google Books API API endpoint to fetch book information by ISBN using multiple open sources.
Optimized for global literature AND German educational books.
Args:
isbn (str): ISBN to look up
Returns:
dict: Book information or error message
""" """
# Authorization Checks
if 'username' not in session or not us.check_admin(session['username']): if 'username' not in session or not us.check_admin(session['username']):
return jsonify({"error": "Not authorized"}), 403 return jsonify({"error": "Not authorized"}), 403
@@ -9454,79 +9553,33 @@ def fetch_book_info(isbn):
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403 return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
try: try:
# Validation
clean_isbn = normalize_and_validate_isbn(isbn) clean_isbn = normalize_and_validate_isbn(isbn)
if not clean_isbn: if not clean_isbn:
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400 return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
# First source: Google Books # Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
response = requests.get( providers = [
f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}", _fetch_from_google_books,
timeout=10 _fetch_from_lobid_germany,
) _fetch_from_open_library
]
if response.status_code == 200: # Iterate through providers until a book is found
data = response.json() for provider in providers:
if data.get('totalItems', 0) > 0 and data.get('items'): book_data = provider(clean_isbn)
book_info = data['items'][0].get('volumeInfo', {})
sale_info = data['items'][0].get('saleInfo', {}) if book_data:
# Add the ISBN back into the response payload
price = None book_data["isbn"] = clean_isbn
retail_price = sale_info.get('retailPrice', {})
list_price = sale_info.get('listPrice', {}) # If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
if retail_price and 'amount' in retail_price: if not book_data.get("thumbnail"):
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '')}" book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
elif list_price and 'amount' in list_price:
price = f"{list_price['amount']} {list_price.get('currencyCode', '')}" return jsonify(book_data)
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
if thumbnail:
thumbnail = thumbnail.replace('http:', 'https:')
return jsonify({
"title": book_info.get('title', 'Unknown Title'),
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
"publisher": book_info.get('publisher', 'Unknown Publisher'),
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
"description": book_info.get('description', 'No description available'),
"pageCount": book_info.get('pageCount', 'Unknown'),
"price": price,
"thumbnail": thumbnail,
"isbn": clean_isbn,
"source": "google-books"
})
# Fallback: OpenLibrary
ol_response = requests.get(
f"https://openlibrary.org/isbn/{clean_isbn}.json",
timeout=10
)
if ol_response.status_code == 200:
ol_data = ol_response.json()
author_names = []
for author_ref in ol_data.get('authors', []):
key = author_ref.get('key')
if not key:
continue
try:
author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8)
if author_resp.status_code == 200:
author_names.append(author_resp.json().get('name'))
except Exception:
continue
return jsonify({
"title": ol_data.get('title', 'Unknown Title'),
"authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author',
"publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher',
"publishedDate": ol_data.get('publish_date', 'Unknown Date'),
"description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available',
"pageCount": ol_data.get('number_of_pages', 'Unknown'),
"price": None,
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
"isbn": clean_isbn,
"source": "openlibrary"
})
# If all providers fail
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404 return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
except Exception as e: except Exception as e:
@@ -10146,6 +10199,7 @@ def admin_damaged_items():
damaged_items=damaged_rows, damaged_items=damaged_rows,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail')
) )
except Exception as exc: except Exception as exc:
app.logger.error(f"Error loading damaged-items admin view: {exc}") app.logger.error(f"Error loading damaged-items admin view: {exc}")
+19 -15
View File
@@ -1,5 +1,6 @@
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from modules.module_registry import ModuleRegistry as mr
import smtplib import smtplib
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
@@ -27,21 +28,24 @@ def send(email: list, subject: str, note: str, sender: str) -> bool:
Output: Output:
- bool: true if the sending worked and false if it didnt - bool: true if the sending worked and false if it didnt
""" """
msg = MIMEMultipart() if not mr.registry.is_enabled('mail'):
msg['Subject'] = subject
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
msg.attach(MIMEText(note))
smtp = None
try:
smtp = _build_smtp_client()
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
return True
except Exception:
return False return False
finally: else:
msg = MIMEMultipart()
msg['Subject'] = subject
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
msg.attach(MIMEText(note))
smtp = None
try: try:
if smtp: smtp = _build_smtp_client()
smtp.quit() smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
return True
except Exception: except Exception:
pass return False
finally:
try:
if smtp:
smtp.quit()
except Exception:
pass
+10 -4
View File
@@ -82,6 +82,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
time_span = item.get('time_span', []) or [] time_span = item.get('time_span', []) or []
creator = item.get('user', 'Terminplaner') creator = item.get('user', 'Terminplaner')
note = item.get('note', '') or '' note = item.get('note', '') or ''
titel = item.get('title', '') or ''
tenant_id = _current_tenant_id() tenant_id = _current_tenant_id()
try: try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True) link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
@@ -108,6 +109,8 @@ def build_calendar_ics(appointment_id: str) -> str | None:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span)) description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note: if note:
description_lines.append('Notiz: ' + str(note)) description_lines.append('Notiz: ' + str(note))
if titel:
description_lines.append('Titel: ' + str(titel))
ics_lines = [ ics_lines = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
@@ -123,6 +126,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
f'URL:{_escape_ics_text(link)}', f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}', f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}', f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
f'Titel:{_escape_ics_text(titel)}',
'END:VEVENT', 'END:VEVENT',
'END:VCALENDAR', 'END:VCALENDAR',
'', '',
@@ -159,7 +163,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
if tenant_id: if tenant_id:
link += f'?tenant={tenant_id}' link += f'?tenant={tenant_id}'
title_name = str(client_name or '').strip() or 'Termin' title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
summary = f"{title_name} - Terminbuchung" summary = f"{title_name} - Terminbuchung"
description_lines = [ description_lines = [
f"Buchungslink: {link}", f"Buchungslink: {link}",
@@ -185,6 +189,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
f'URL:{_escape_ics_text(link)}', f'URL:{_escape_ics_text(link)}',
f'DTSTART:{dt_start}', f'DTSTART:{dt_start}',
f'DTEND:{dt_end}', f'DTEND:{dt_end}',
f'Titel:{_escape_ics_text(summary)}',
'END:VEVENT', 'END:VEVENT',
'END:VCALENDAR', 'END:VCALENDAR',
'', '',
@@ -192,7 +197,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return '\r\n'.join(ics_lines) return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict: def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="") -> dict:
""" """
Generates a link for the executive to send to his clients to book a time Slot Generates a link for the executive to send to his clients to book a time Slot
@@ -208,7 +213,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
""" """
normalized_time_span = _normalize_time_span(time_span) normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail) normalized_mail = _normalize_mail_list(mail)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled) id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
id_str = str(id) id_str = str(id)
tenant_id = _current_tenant_id() tenant_id = _current_tenant_id()
@@ -219,7 +224,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
link = host + "/terminplaner/client/" + id_str link = host + "/terminplaner/client/" + id_str
if tenant_id: if tenant_id:
link += f"?tenant={tenant_id}" link += f"?tenant={tenant_id}"
subject = f"Terminanfrage von {user}" subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}" note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None calendar_link = None
if calendar_enabled: if calendar_enabled:
@@ -459,6 +464,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
'note': str(item.get('note') or ''), 'note': str(item.get('note') or ''),
'link': link, 'link': link,
'calendar_link': calendar_link if item.get('calendar_enabled') else None, 'calendar_link': calendar_link if item.get('calendar_enabled') else None,
'title': str(item.get('title') or ''),
} }
) )
+11 -4
View File
@@ -173,8 +173,9 @@ def configure():
mail = request.form.get('mail', '') mail = request.form.get('mail', '')
note = request.form.get('note', '') note = request.form.get('note', '')
add_to_calendar = request.form.get('add_to_calendar') == 'on' add_to_calendar = request.form.get('add_to_calendar') == 'on'
titel = request.form.get('titel', '').strip()
if not start or not end or not time or not slots_amount or not slot_lenght: if not start or not end or not time or not slots_amount or not slot_lenght or not titel:
flash('Bitte alle Pflichtfelder ausfüllen.', 'error') flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -183,7 +184,7 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
) )
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar) result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar, title=titel)
flash('Der Terminplan wurde angelegt.', 'success') flash('Der Terminplan wurde angelegt.', 'success')
return render_template( return render_template(
'termin_configure.html', 'termin_configure.html',
@@ -192,6 +193,7 @@ def configure():
calendar_link=result.get('calendar_link'), calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar, add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=titel,
) )
elif request.method == "GET": elif request.method == "GET":
return render_template( return render_template(
@@ -201,6 +203,7 @@ def configure():
calendar_link=None, calendar_link=None,
add_to_calendar=False, add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED, email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
) )
@@ -214,8 +217,10 @@ def calendar_export(appointment_id):
if not ics_content: if not ics_content:
return _appointment_not_found_response() return _appointment_not_found_response()
title = str(request.args.get('title', '') or '').strip() or appointment_id
response = Response(ics_content, mimetype='text/calendar; charset=utf-8') response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics' response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
return response return response
@@ -231,8 +236,10 @@ def client_slot_calendar_export(appointment_id):
if not ics_content: if not ics_content:
return _appointment_not_found_response() return _appointment_not_found_response()
title = str(request.args.get('title', '') or '').strip() or appointment_id
response = Response(ics_content, mimetype='text/calendar; charset=utf-8') response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics' response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
return response return response
@appoint_bp.route('/') @appoint_bp.route('/')
-1
View File
@@ -15,4 +15,3 @@ openpyxl
cryptography>=42.0.0 cryptography>=42.0.0
pywebpush pywebpush
py-vapid>=1.9.0 py-vapid>=1.9.0
pyzbar
-296
View File
@@ -1,296 +0,0 @@
/**
* Hybrid Barcode Scanner
* Supports: Real-time camera scanning (ZXing.js), image upload fallback (server-side pyzbar),
* and USB/keyboard scanner emulation.
*/
class HybridScanner {
constructor(options = {}) {
this.options = {
videoId: options.videoId || 'scanner-video',
canvasId: options.canvasId || 'scanner-canvas',
formats: options.formats || ['QR_CODE', 'CODE_128', 'EAN_13', 'ISBN', 'CODE_39', 'UPC_A'],
fps: options.fps || 10,
onSuccess: options.onSuccess || (() => {}),
onError: options.onError || (() => {}),
facingMode: options.facingMode || 'environment', // 'environment' for rear, 'user' for front
};
this.state = {
isRunning: false,
stream: null,
reader: null,
canvas: null,
videoElement: null,
zxingReady: false,
lastScanTime: 0,
scanDebounceMs: 300, // Prevent rapid duplicate scans
};
this.keyboardInputBuffer = '';
this.keyboardInputTimeout = null;
}
/**
* Initialize ZXing library from CDN
*/
async initZXing() {
if (this.state.zxingReady) {
return true;
}
return new Promise((resolve) => {
// Load ZXing library from CDN
if (typeof ZXing !== 'undefined') {
this.state.zxingReady = true;
resolve(true);
return;
}
const script = document.createElement('script');
script.src = 'https://unpkg.com/@zxing/library@latest/umd/index.min.js';
script.onload = () => {
this.state.zxingReady = typeof ZXing !== 'undefined';
resolve(this.state.zxingReady);
};
script.onerror = () => {
console.error('Failed to load ZXing library');
resolve(false);
};
document.head.appendChild(script);
});
}
/**
* Start real-time barcode scanning via camera
*/
async start() {
if (this.state.isRunning) {
return;
}
try {
// Ensure ZXing is loaded
const zxingReady = await this.initZXing();
if (!zxingReady) {
this.options.onError('ZXing library failed to load. Please use image upload fallback.');
return;
}
// Get DOM elements
this.state.videoElement = document.getElementById(this.options.videoId);
this.state.canvas = document.getElementById(this.options.canvasId);
if (!this.state.videoElement || !this.state.canvas) {
this.options.onError('Scanner video or canvas element not found.');
return;
}
// Request camera access
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: this.options.facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
this.state.stream = stream;
this.state.videoElement.srcObject = stream;
// Make video element visible (ensure it's not hidden by display: none)
this.state.videoElement.style.display = 'block';
// Initialize ZXing reader
const hints = new Map();
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, this.options.formats);
hints.set(ZXing.DecodeHintType.TRY_HARDER, true);
this.state.reader = new ZXing.BrowserMultiFormatReader(hints);
this.state.isRunning = true;
// Start scanning loop
this.scanLoop();
// Setup keyboard input fallback for USB scanners
this.setupKeyboardInput();
} catch (err) {
this.options.onError(`Camera error: ${err.message || err}`);
}
}
/**
* Stop barcode scanning
*/
stop() {
if (!this.state.isRunning) {
return;
}
this.state.isRunning = false;
if (this.state.stream) {
this.state.stream.getTracks().forEach((track) => track.stop());
this.state.stream = null;
}
if (this.state.videoElement) {
this.state.videoElement.srcObject = null;
this.state.videoElement.style.display = 'none';
}
this.removeKeyboardInput();
}
/**
* Main scanning loop for real-time frame processing
*/
scanLoop() {
if (!this.state.isRunning) {
return;
}
const canvas = this.state.canvas;
const video = this.state.videoElement;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
if (context) {
context.drawImage(video, 0, 0);
try {
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const luminanceSource = new ZXing.HTMLCanvasElementLuminanceSource(canvas);
const binaryBitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(luminanceSource));
try {
const result = this.state.reader.decodeFromBitmap(binaryBitmap);
this.handleScanResult(result.text);
} catch (e) {
// No barcode found in frame; continue scanning
}
} catch (err) {
// Silently ignore canvas errors
}
}
}
// Throttle scan loop
setTimeout(() => this.scanLoop(), 1000 / this.options.fps);
}
/**
* Handle successful barcode scan with debouncing
*/
handleScanResult(scannedText) {
const now = Date.now();
if (now - this.state.lastScanTime < this.state.scanDebounceMs) {
return; // Ignore rapid duplicates
}
this.state.lastScanTime = now;
this.options.onSuccess(scannedText.trim());
}
/**
* Setup keyboard input listener for USB scanner emulation
* Most USB scanners emulate keyboard input (barcode followed by Enter)
*/
setupKeyboardInput() {
this.keyboardListener = (event) => {
// Allow only alphanumeric, dash, colon (common in barcodes)
if (/^[a-zA-Z0-9:_\-]$/.test(event.key)) {
event.preventDefault();
this.keyboardInputBuffer += event.key;
// Reset timeout for multi-part barcodes
if (this.keyboardInputTimeout) {
clearTimeout(this.keyboardInputTimeout);
}
} else if (event.key === 'Enter') {
event.preventDefault();
if (this.keyboardInputBuffer.length > 0) {
this.handleScanResult(this.keyboardInputBuffer);
this.keyboardInputBuffer = '';
}
} else if (event.key === 'Escape') {
this.keyboardInputBuffer = '';
this.stop();
}
// Auto-trigger if buffer looks complete (common barcode lengths)
if ([8, 12, 13, 17].includes(this.keyboardInputBuffer.length)) {
this.keyboardInputTimeout = setTimeout(() => {
if (this.keyboardInputBuffer.length > 0) {
this.handleScanResult(this.keyboardInputBuffer);
this.keyboardInputBuffer = '';
}
}, 200);
}
};
document.addEventListener('keydown', this.keyboardListener);
}
/**
* Remove keyboard input listener
*/
removeKeyboardInput() {
if (this.keyboardListener) {
document.removeEventListener('keydown', this.keyboardListener);
this.keyboardListener = null;
}
this.keyboardInputBuffer = '';
}
/**
* Upload image for server-side decoding (fallback)
* Requires /api/scan_upload endpoint
*/
static async uploadImageForDecoding(file, onSuccess, onError) {
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/scan_upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.ok && data.barcodes && data.barcodes.length > 0) {
// Return first detected barcode (highest confidence)
onSuccess(data.barcodes[0].value);
} else {
onError(data.message || 'No barcode detected in image.');
}
} catch (err) {
onError(`Upload error: ${err.message}`);
}
}
/**
* Toggle camera (front/rear on mobile)
*/
toggleCamera() {
if (this.options.facingMode === 'environment') {
this.options.facingMode = 'user';
} else {
this.options.facingMode = 'environment';
}
// Restart scanning with new camera
this.stop();
this.start();
}
}
// Export for use in templates
if (typeof window !== 'undefined') {
window.HybridScanner = HybridScanner;
}
-5
View File
@@ -1295,11 +1295,6 @@
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %} {% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
+59 -197
View File
@@ -10,24 +10,6 @@
<h1>📚 Bibliothek</h1> <h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p> <p class="subtitle">Bücher, CDs und weitere Medien</p>
</div> </div>
<div class="header-controls">
<!-- Favorites Toggle -->
<div class="view-switch">
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
<span class="toggle-icon"></span>
<span class="toggle-label">Favoriten</span>
</button>
</div>
<!-- View Mode Toggle -->
<div class="view-switch">
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
<span class="toggle-icon"></span>
<span class="toggle-label">Tabelle</span>
</button>
</div>
</div>
</div> </div>
<!-- Search & Filter Section --> <!-- Search & Filter Section -->
@@ -67,22 +49,8 @@
<!-- Scanner Panel --> <!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;"> <div id="qrContainer" class="qr-container" style="display: none;">
<div class="scanner-header"> <div id="qr-reader" style="width: auto; height: 300px;"></div>
<h3>Barcode/QR-Code Scanner</h3> <span id="qr-result" style="display: none;"></span>
<div class="scanner-controls">
<button type="button" id="cameraToggle" class="scanner-button" title="Kamera wechseln">📷 Kamera</button>
<label for="uploadScanImage" class="scanner-button" title="Bild hochladen zum Dekodieren">📤 Upload</label>
<input type="file" id="uploadScanImage" accept="image/*" style="display: none;">
</div>
</div>
<div class="scanner-video-wrapper">
<video id="qr-reader" playsinline style="width: 100%; max-width: 500px; border-radius: 8px;"></video>
<canvas id="scanner-canvas" style="display: none;"></canvas>
</div>
<div id="scanMessage" class="scanner-message" style="margin-top: 10px; text-align: center; font-weight: bold; color: #666;"></div>
<p style="font-size: 0.9em; color: #999; margin-top: 10px; text-align: center;">
💡 Hinweis: Scan-Ergebnis wird automatisch in das Feld oben übernommen. USB-Scanner werden als Tastatureingabe erkannt.
</p>
</div> </div>
<!-- Table Header (only in table mode) --> <!-- Table Header (only in table mode) -->
@@ -219,9 +187,7 @@
</form> </form>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script> <script>
// View mode persistence // View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode'; const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
@@ -640,103 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
closeAllFilters(); closeAllFilters();
}); });
// Favorites toggle // Scanner toggle
document.getElementById('favoriteToggle').addEventListener('click', function() { // Scanner state tracking
this.classList.toggle('open'); let isScanning = false;
// TODO: Implement favorites filtering
});
// Scanner instance (hybrid: real-time + upload + keyboard)
let scanner = null;
document.getElementById('scannerBtn').addEventListener('click', function(e) { document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
const container = document.getElementById('qrContainer'); const container = document.getElementById('qrContainer');
const isOpen = container.style.display !== 'none'; const isOpen = container.style.display !== 'none';
const btn = this;
if (isOpen) { if (isOpen) {
// Close the scanner
container.style.display = 'none'; container.style.display = 'none';
if (scanner) { Quagga.stop();
scanner.stop(); isScanning = false;
scanner = null;
} btn.classList.remove('open');
this.classList.remove('open'); btn.setAttribute('aria-expanded', 'false');
this.setAttribute('aria-expanded', 'false');
} else { } else {
// Open the scanner
container.style.display = 'block'; container.style.display = 'block';
this.classList.add('open'); btn.classList.add('open');
this.setAttribute('aria-expanded', 'true'); btn.setAttribute('aria-expanded', 'true');
if (!scanner) { Quagga.init({
initScanner(); inputStream: {
} name: "Live",
} type: "LiveStream",
}); // Targets the element where the video stream will inject
target: document.querySelector('#qr-reader'),
function initScanner() { constraints: {
scanner = new HybridScanner({ width: 640,
videoId: 'qr-reader', height: 480,
canvasId: 'scanner-canvas', facingMode: "environment" // Forces back camera
formats: ['QR_CODE', 'CODE_128', 'EAN_13', 'ISBN', 'CODE_39', 'UPC_A', 'EAN_8'], },
fps: 10,
facingMode: 'environment',
onSuccess: function(decodedText) {
const studentId = decodedText.trim();
document.getElementById('studentIdInput').value = studentId;
document.getElementById('scanMessage').textContent = '✓ Erkannt: ' + studentId;
document.getElementById('scanMessage').style.color = '#4CAF50';
},
onError: function(error) {
document.getElementById('scanMessage').textContent = '✗ ' + error;
document.getElementById('scanMessage').style.color = '#f44336';
}
});
scanner.start();
}
// Camera toggle
document.getElementById('cameraToggle')?.addEventListener('click', function() {
if (scanner) {
scanner.toggleCamera();
this.textContent = scanner.options.facingMode === 'user' ? '📷 Rückkamera' : '🤳 Frontkamera';
}
});
// Image upload fallback
document.getElementById('uploadScanImage')?.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const messageEl = document.getElementById('scanMessage');
messageEl.textContent = 'Wird dekodiert...';
messageEl.style.color = '#2196F3';
HybridScanner.uploadImageForDecoding(
file,
(decodedText) => {
document.getElementById('studentIdInput').value = decodedText;
messageEl.textContent = '✓ Erkannt (Upload): ' + decodedText;
messageEl.style.color = '#4CAF50';
e.target.value = ''; // Reset file input
}, },
(error) => { decoder: {
messageEl.textContent = '✗ ' + error; // Optimized for 1D barcodes (e.g., student IDs, member cards)
messageEl.style.color = '#f44336'; readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
e.target.value = ''; }
}, function(err) {
if (err) {
console.error("Initialization error:", err);
alert("Kamera konnte nicht gestartet werden.");
return;
} }
); Quagga.start();
isScanning = true;
});
} }
}); });
function onScanSuccess(decodedText, decodedResult) { // Single Quagga event listener for successful scans
const studentId = decodedText.trim(); Quagga.onDetected(function(data) {
const decodedText = data.codeResult.code;
const studentId = String(decodedText || '').trim();
// Stop scanning immediately to prevent multiple triggers
Quagga.stop();
isScanning = false;
// Reset UI states to closed
const container = document.getElementById('qrContainer');
const btn = document.getElementById('scannerBtn');
if (container) container.style.display = 'none';
if (btn) {
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
}
// Route the scanned data to your input
document.getElementById('studentIdInput').value = studentId; document.getElementById('studentIdInput').value = studentId;
alert('Ausweis gescannt: ' + studentId); alert('Ausweis gescannt: ' + studentId);
} });
function onScanError(error) {
// Silently ignore scan errors
}
</script> </script>
<style> <style>
@@ -805,86 +748,5 @@ function onScanError(error) {
display: block; /* keep rows stacked but allow horizontal scroll */ display: block; /* keep rows stacked but allow horizontal scroll */
} }
} }
/* Hybrid Scanner Styles */
.qr-container {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.scanner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 12px;
}
.scanner-header h3 {
margin: 0;
color: #333;
font-size: 1.1em;
}
.scanner-controls {
display: flex;
gap: 8px;
}
.scanner-button {
background: #2196F3;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.scanner-button:hover {
background: #1976D2;
}
.scanner-button:active {
background: #1565C0;
}
.scanner-video-wrapper {
display: flex;
justify-content: center;
margin: 12px 0;
}
#qr-reader {
border: 2px solid #2196F3;
border-radius: 8px;
}
.scanner-message {
min-height: 20px;
font-size: 0.95em;
text-align: center;
}
@media (max-width: 600px) {
.scanner-header {
flex-direction: column;
align-items: stretch;
}
.scanner-controls {
width: 100%;
justify-content: space-between;
}
.scanner-button {
flex: 1;
}
}
</style> </style>
{% endblock %} {% endblock %}
+421 -376
View File
@@ -431,7 +431,7 @@
type="text" type="text"
id="librarySearch" id="librarySearch"
class="library-search-input" class="library-search-input"
placeholder="Nach Titel, Autor, ISBN suchen..." placeholder="Nach Titel, ISBN suchen..."
> >
<button <button
id="filterToggleBtn" id="filterToggleBtn"
@@ -445,7 +445,7 @@
<!-- Customizable Filter Panel --> <!-- Customizable Filter Panel -->
<!-- Student card / quick scan workflow --> <!-- Student card / quick scan workflow -->
<div class="library-scan-panel"> <div class="library-scan-panel">
<div class="library-scan-controls"> <div class="library-scan-controls">
<select id="scanModeSelect" aria-label="Scan-Modus"> <select id="scanModeSelect" aria-label="Scan-Modus">
<option value="card_only">Nur Ausweis erfassen</option> <option value="card_only">Nur Ausweis erfassen</option>
@@ -458,19 +458,14 @@
<div id="scanStatus" class="library-scan-status"> <div id="scanStatus" class="library-scan-status">
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode. Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode.
</div> </div>
<div id="scanReaderWrap" class="library-scan-reader-wrap"> <div id="scanReaderWrap" class="library-scan-reader-wrap" style="display: none;">
<div id="libraryQrReader" class="library-scan-reader"> <div id="libraryQrReader" class="library-scan-reader">
<video id="libraryQrReaderVideo" playsinline style="width: 100%; border-radius: 8px;"></video> <div id="library-scanner-container"></div>
<canvas id="libraryQrReaderCanvas" style="display: none;"></canvas>
</div> </div>
</div> </div>
</div> </div>
<div id="filterPanel" class="library-filter-panel"> <div id="filterPanel" class="library-filter-panel">
<div class="filter-row"> <div class="filter-row">
<div class="filter-item">
<label for="filterAuthor">Autor/Künstler:</label>
<input type="text" id="filterAuthor" placeholder="z.B. Goethe">
</div>
<div class="filter-item"> <div class="filter-item">
<label for="filterISBN">ISBN:</label> <label for="filterISBN">ISBN:</label>
<input type="text" id="filterISBN" placeholder="z.B. 978-3..."> <input type="text" id="filterISBN" placeholder="z.B. 978-3...">
@@ -517,7 +512,6 @@
<thead> <thead>
<tr> <tr>
<th style="width: 24%;">Titel</th> <th style="width: 24%;">Titel</th>
<th style="width: 14%;">Autor/Künstler</th>
<th style="width: 12%;">ISBN/Code</th> <th style="width: 12%;">ISBN/Code</th>
<th style="width: 8%;">Typ</th> <th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th> <th style="width: 8%;">Anzahl</th>
@@ -550,16 +544,17 @@
</div> </div>
</div> </div>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="/static/js/scanner.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script> <script>
// State // =========================================================================
// 1. GLOBAL STATE DEFINITIONS
// =========================================================================
let libraryItems = []; let libraryItems = [];
let filteredItems = []; let filteredItems = [];
let visibleItems = []; let visibleItems = [];
let currentSearchTerm = ''; let currentSearchTerm = '';
let activeFilters = { let activeFilters = {
author: '',
isbn: '', isbn: '',
type: '', type: '',
status: '' status: ''
@@ -575,13 +570,20 @@
const RENDER_BATCH_COUNT = 120; const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT; let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false; let filterPanelOpen = false;
// Scanner Related State Variables
let scannerInstance = null; let scannerInstance = null;
let scannerRunning = false; let scannerRunning = false;
let activeScannerCallback = null;
let activeStudentCardId = ''; let activeStudentCardId = '';
let lastScanValue = ''; let lastScanValue = '';
let lastScanAt = 0; let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1'); const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
// =========================================================================
// 2. DATA LOADING & FILTERING ENGINE
// =========================================================================
async function fetchLibraryPage(offset, limit) { async function fetchLibraryPage(offset, limit) {
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`); const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
if (!response.ok) { if (!response.ok) {
@@ -590,7 +592,6 @@
return response.json(); return response.json();
} }
// Load items progressively: first page immediately, rest in background
async function loadLibraryItems() { async function loadLibraryItems() {
if (pagingState.loading) return; if (pagingState.loading) return;
pagingState.loading = true; pagingState.loading = true;
@@ -629,7 +630,6 @@
pagingState.total = page.total || pagingState.total; pagingState.total = page.total || pagingState.total;
pagingState.hasMore = Boolean(page.has_more); pagingState.hasMore = Boolean(page.has_more);
// Keep the current view reactive while avoiding a full rerender burst.
applyFiltersAndSearch(false); applyFiltersAndSearch(false);
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
@@ -645,18 +645,16 @@
} }
function filterItems(items) { function filterItems(items) {
const author = activeFilters.author;
const isbn = activeFilters.isbn; const isbn = activeFilters.isbn;
const type = activeFilters.type; const type = activeFilters.type;
const status = activeFilters.status; const status = activeFilters.status;
return items.filter(item => { return items.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn); const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type; const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available'); const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status; const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch; return isbnMatch && typeMatch && statusMatch;
}); });
} }
@@ -664,7 +662,6 @@
if (!currentSearchTerm) return items; if (!currentSearchTerm) return items;
return items.filter(item => return items.filter(item =>
(item.Name || '').toLowerCase().includes(currentSearchTerm) || (item.Name || '').toLowerCase().includes(currentSearchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm) (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
); );
} }
@@ -681,7 +678,6 @@
renderItems(); renderItems();
} }
// Display current view with incremental rendering
function renderItems() { function renderItems() {
const tbody = document.getElementById('itemsTableBody'); const tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState'); const emptyState = document.getElementById('emptyState');
@@ -699,37 +695,33 @@
emptyState.style.display = 'none'; emptyState.style.display = 'none';
const rowsToRender = visibleItems.slice(0, renderedCount); const rowsToRender = visibleItems.slice(0, renderedCount);
tbody.innerHTML = rowsToRender.map(item => ` tbody.innerHTML = rowsToRender.map(item => {
${(() => { const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available'); const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available'); const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar'); const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar'); const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
const actionDisabled = statusKey === 'damaged' ? 'disabled' : ''; return `
return ` <tr>
<tr> <td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td> <td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</td> <td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td> <td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td> <td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td> <span class="table-status ${statusClass}">
<td> ${statusText}
<span class="table-status ${statusClass}"> </span>
${statusText} </td>
</span> <td class="table-actions">
</td> <button class="button" onclick="showItemDetail('${item._id}')">Details</button>
<td class="table-actions"> <button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
<button class="button" onclick="showItemDetail('${item._id}')">Details</button> ${actionLabel}
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}> </button>
${actionLabel} ${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
</button> ${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''} </td>
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''} </tr>`;
</td> }).join('');
</tr>
`;
})()}
`).join('');
const canLoadMoreRows = renderedCount < visibleItems.length; const canLoadMoreRows = renderedCount < visibleItems.length;
if (canLoadMoreRows) { if (canLoadMoreRows) {
@@ -744,13 +736,6 @@
} }
} }
// Confirm and call library item delete endpoint
function confirmDeleteLibraryItem(itemId) {
if (!itemId) return;
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
deleteLibraryItem(itemId);
}
async function deleteLibraryItem(itemId) { async function deleteLibraryItem(itemId) {
if (!itemId) return; if (!itemId) return;
document.body.style.cursor = 'wait'; document.body.style.cursor = 'wait';
@@ -774,7 +759,6 @@
} }
loadLibraryItems(); loadLibraryItems();
} else { } else {
// Server redirected to HTML (flash messages). Reload to show state.
location.reload(); location.reload();
} }
} catch (e) { } catch (e) {
@@ -785,46 +769,269 @@
} }
} }
// Filter toggle function confirmDeleteLibraryItem(itemId) {
document.getElementById('filterToggleBtn').addEventListener('click', () => { if (!itemId) return;
filterPanelOpen = !filterPanelOpen; if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
const panel = document.getElementById('filterPanel'); deleteLibraryItem(itemId);
const btn = document.getElementById('filterToggleBtn'); }
panel.classList.toggle('open');
btn.classList.toggle('active'); // =========================================================================
// 3. CORE SCANNER ROUTING ENGINE (QUAGGA2)
// =========================================================================
function startScanner(targetCallback) {
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
activeScannerCallback = targetCallback;
if (readerWrap) readerWrap.style.display = 'block';
setScanStatus('Initializing camera...', 'warn');
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: document.querySelector('#library-scanner-container'),
constraints: {
width: 640,
height: 480,
facingMode: "environment"
},
},
decoder: {
readers: [
"code_128_reader",
"ean_reader",
"code_39_reader",
"upc_reader",
"codabar_reader",
"i2of5_reader"
]
}
}, function(err) {
if (err) {
console.error('Scanner start failed:', err);
if (readerWrap) readerWrap.style.display = 'none';
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
return;
}
Quagga.start();
scannerRunning = true;
if (!targetCallback && toggleBtn) {
toggleBtn.textContent = 'Scanner stoppen';
}
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
});
}
function stopScanner() {
if (!scannerRunning) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
Quagga.stop();
scannerRunning = false;
activeScannerCallback = null;
if (readerWrap) readerWrap.style.display = 'none';
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
}
Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return;
const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode);
const currentCallback = activeScannerCallback;
stopScanner();
if (typeof currentCallback === "function") {
currentCallback(barcode);
} else {
handleScanSuccess(barcode);
}
}); });
// Apply filters function handleScanSuccess(decodedText) {
document.getElementById('applyFilterBtn').addEventListener('click', () => { const scannedCode = normalizeScannedCode(decodedText);
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase(); if (!scannedCode) return;
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value; const now = Date.now();
activeFilters.status = document.getElementById('filterStatus').value; if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
applyFiltersAndSearch(true); return;
}); }
lastScanValue = scannedCode;
lastScanAt = now;
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
processQuickToggleScan(scannedCode);
}
// Clear filters async function processQuickToggleScan(scannedCode) {
document.getElementById('clearFilterBtn').addEventListener('click', () => { if (!activeStudentCardId) {
document.getElementById('filterAuthor').value = ''; setActiveStudentCard(scannedCode);
document.getElementById('filterISBN').value = ''; setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
document.getElementById('filterType').value = ''; return;
document.getElementById('filterStatus').value = ''; }
activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true); try {
}); setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
student_card_id: activeStudentCardId,
item_code: scannedCode
})
});
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
return;
}
if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
}
await loadLibraryItems();
} catch (err) {
console.error('Quick scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
}
}
// Search input function scanIntoEditCode() {
document.getElementById('librarySearch').addEventListener('input', (e) => { const scanReaderWrap = document.getElementById('scanReaderWrap');
currentSearchTerm = (e.target.value || '').toLowerCase(); const editCodeInput = document.getElementById('edit-code4');
applyFiltersAndSearch(true); const scanEditBtn = document.getElementById('scan-edit-code-btn');
}); if (!scanReaderWrap || !editCodeInput || !scanEditBtn) {
return;
}
if (scannerRunning && scanReaderWrap.style.display !== 'none') {
stopScanner();
scanEditBtn.textContent = 'Barcode scannen';
return;
}
scanEditBtn.textContent = 'Scanner schließen';
startScanner(function(decodedText) {
editCodeInput.value = decodedText;
if(typeof validateCodeField === "function") {
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
}
scanEditBtn.textContent = 'Barcode scannen';
});
}
document.getElementById('loadMoreBtn').addEventListener('click', () => { // =========================================================================
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length); // 4. UI INTERACTIONS & UTILITIES
renderItems(); // =========================================================================
}); function borrowItem(itemId) {
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
return;
}
const defaultCardId = activeStudentCardId || '';
const cardId = (window.prompt('Bitte Schülerausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
if (!cardId) {
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.');
return;
}
setActiveStudentCard(cardId);
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
let borrowCount = parseInt(countPrompt || '1', 10);
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
borrowCount = 1;
}
if (borrowCount > maxAvailable) {
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = `/ausleihen/${itemId}`;
const csrfField = document.createElement('input');
csrfField.type = 'hidden';
csrfField.name = 'csrf_token';
csrfField.value = '{{ csrf_token }}';
form.appendChild(csrfField);
const cardField = document.createElement('input');
cardField.type = 'hidden';
cardField.name = 'borrower_card_id';
cardField.value = cardId;
form.appendChild(cardField);
const returnTargetField = document.createElement('input');
returnTargetField.type = 'hidden';
returnTargetField.name = 'return_to';
returnTargetField.value = 'library';
form.appendChild(returnTargetField);
if (durationInput) {
const durationField = document.createElement('input');
durationField.type = 'hidden';
durationField.name = 'borrow_duration_days';
durationField.value = durationInput;
form.appendChild(durationField);
}
const countField = document.createElement('input');
countField.type = 'hidden';
countField.name = 'exemplare_count';
countField.value = String(borrowCount || 1);
form.appendChild(countField);
document.body.appendChild(form);
form.submit();
}
function setScanStatus(message, kind = '') {
const el = document.getElementById('scanStatus');
if (!el) return;
el.textContent = message;
el.classList.remove('ok', 'warn', 'error');
if (kind) el.classList.add(kind);
}
function setActiveStudentCard(cardId) {
activeStudentCardId = (cardId || '').trim().toUpperCase();
const input = document.getElementById('activeStudentCard');
if (input) input.value = activeStudentCardId;
}
function normalizeScannedCode(code) {
return (code || '').trim();
}
// Helper functions
function getItemTypeLabel(type) { function getItemTypeLabel(type) {
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' }; const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
return labels[type] || type; return labels[type] || type;
@@ -837,7 +1044,6 @@
} }
function showItemDetail(itemId) { function showItemDetail(itemId) {
// Load and show detail modal
fetch(`/api/item_detail/${itemId}`) fetch(`/api/item_detail/${itemId}`)
.then(r => r.text()) .then(r => r.text())
.then(html => { .then(html => {
@@ -851,322 +1057,165 @@
document.getElementById('detailModal').style.display = 'none'; document.getElementById('detailModal').style.display = 'none';
} }
function borrowItem(itemId) { // =========================================================================
const selectedItem = (libraryItems || []).find(item => item._id === itemId); // 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') { // =========================================================================
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
return;
}
const defaultCardId = activeStudentCardId || '';
const cardId = (window.prompt('Bitte Schülerausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
if (!cardId) {
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Schülerausweis-ID erforderlich.');
return;
}
setActiveStudentCard(cardId);
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
let borrowCount = parseInt(countPrompt || '1', 10);
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
borrowCount = 1;
}
if (borrowCount > maxAvailable) {
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = `/ausleihen/${itemId}`;
const csrfField = document.createElement('input');
csrfField.type = 'hidden';
csrfField.name = 'csrf_token';
csrfField.value = '{{ csrf_token }}';
form.appendChild(csrfField);
const cardField = document.createElement('input');
cardField.type = 'hidden';
cardField.name = 'borrower_card_id';
cardField.value = cardId;
form.appendChild(cardField);
const returnTargetField = document.createElement('input');
returnTargetField.type = 'hidden';
returnTargetField.name = 'return_to';
returnTargetField.value = 'library';
form.appendChild(returnTargetField);
if (durationInput) {
const durationField = document.createElement('input');
durationField.type = 'hidden';
durationField.name = 'borrow_duration_days';
durationField.value = durationInput;
form.appendChild(durationField);
}
const countField = document.createElement('input');
countField.type = 'hidden';
countField.name = 'exemplare_count';
countField.value = String(borrowCount || 1);
form.appendChild(countField);
document.body.appendChild(form);
form.submit();
}
function setScanStatus(message, kind = '') {
const el = document.getElementById('scanStatus');
if (!el) return;
el.textContent = message;
el.classList.remove('ok', 'warn', 'error');
if (kind) {
el.classList.add(kind);
}
}
function setActiveStudentCard(cardId) {
activeStudentCardId = (cardId || '').trim().toUpperCase();
const input = document.getElementById('activeStudentCard');
if (input) {
input.value = activeStudentCardId;
}
}
function normalizeScannedCode(code) {
return (code || '').trim();
}
async function processQuickToggleScan(scannedCode) {
if (!activeStudentCardId) {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
student_card_id: activeStudentCardId,
item_code: scannedCode
})
});
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
return;
}
if (result.action === 'borrowed') {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
}
await loadLibraryItems();
} catch (err) {
console.error('Quick scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
}
}
function handleScanSuccess(decodedText) {
const scannedCode = normalizeScannedCode(decodedText);
if (!scannedCode) return;
const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return;
}
lastScanValue = scannedCode;
lastScanAt = now;
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
processQuickToggleScan(scannedCode);
}
function handleScanError() {
// Intentionally silent to avoid UI spam while camera searches codes.
}
async function ensureScannerLibraryLoaded() {
// HybridScanner is loaded globally via script tag
return typeof HybridScanner !== 'undefined';
}
async function startScanner() {
if (scannerRunning) return;
const scannerLoaded = await ensureScannerLibraryLoaded();
if (!scannerLoaded) {
setScanStatus('Scanner-Bibliothek konnte nicht geladen werden.', 'error');
return;
}
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
readerWrap.style.display = 'block';
try {
scannerInstance = scannerInstance || new HybridScanner({
videoId: 'libraryQrReaderVideo',
canvasId: 'libraryQrReaderCanvas',
formats: ['QR_CODE', 'EAN_13', 'EAN_8', 'CODE_128', 'CODE_39', 'UPC_A', 'UPC_E', 'ITF', 'CODABAR'],
fps: 10,
facingMode: 'environment',
onSuccess: handleScanSuccess,
onError: handleScanError
});
scannerInstance.start();
scannerRunning = true;
toggleBtn.textContent = 'Scanner stoppen';
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
} catch (err) {
console.error('Scanner start failed:', err);
readerWrap.style.display = 'none';
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
}
}
async function stopScanner() {
if (!scannerRunning || !scannerInstance) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
try {
scannerInstance.stop();
} catch (err) {
console.error('Scanner stop failed:', err);
}
scannerRunning = false;
scannerInstance = null;
readerWrap.style.display = 'none';
toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
}
function wireScannerUi() { function wireScannerUi() {
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
const resetBtn = document.getElementById('resetCardBtn'); const resetBtn = document.getElementById('resetCardBtn');
const modeSelect = document.getElementById('scanModeSelect'); const modeSelect = document.getElementById('scanModeSelect');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.addEventListener('click', async () => { toggleBtn.addEventListener('click', async () => {
if (scannerRunning) { if (scannerRunning) {
await stopScanner(); await stopScanner();
} else { } else {
await startScanner(); await startScanner(null);
} }
}); });
} }
if (resetBtn) { if (resetBtn) {
resetBtn.addEventListener('click', () => { resetBtn.addEventListener('click', () => {
setActiveStudentCard(''); setActiveStudentCard('');
setScanStatus('Ausweis zurückgesetzt. Bitte neu scannen.', 'warn'); setScanStatus('Ausweis zurückgesetzt. Bitte neu scannen.', 'warn');
}); });
} }
if (modeSelect) { if (modeSelect) {
modeSelect.addEventListener('change', () => { modeSelect.addEventListener('change', () => {
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) { if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn'); setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn');
} else if (modeSelect.value === 'card_only') { } else if (modeSelect.value === 'card_only') {
setScanStatus('Modus aktiv: Nur Ausweis erfassen.', 'warn'); setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
} else {
setScanStatus('Schnellmodus aktiv: jetzt Mediencode scannen.', 'warn');
} }
}); });
} }
} }
function getLibraryItemById(itemId) { // Run when DOM structure is entirely ready
return libraryItems.find(i => String(i._id) === String(itemId)); document.addEventListener('DOMContentLoaded', () => {
} wireScannerUi(); // Setup scanner control buttons
loadLibraryItems(); // Fetch your database items right away!
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
const filterToggleBtn = document.getElementById('filterToggleBtn');
if (filterToggleBtn) {
filterToggleBtn.addEventListener('click', () => {
filterPanelOpen = !filterPanelOpen;
document.getElementById('filterPanel').classList.toggle('open');
filterToggleBtn.classList.toggle('active');
});
}
const applyFilterBtn = document.getElementById('applyFilterBtn');
if (applyFilterBtn) {
applyFilterBtn.addEventListener('click', () => {
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
}
const clearFilterBtn = document.getElementById('clearFilterBtn');
if (clearFilterBtn) {
clearFilterBtn.addEventListener('click', () => {
document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = '';
activeFilters = { isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
});
}
const librarySearch = document.getElementById('librarySearch');
if (librarySearch) {
librarySearch.addEventListener('input', (e) => {
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
}
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
}
// Edit Modal Form processing
const editForm = document.getElementById('editLibraryForm');
if (editForm) {
editForm.addEventListener('submit', async function(e) {
e.preventDefault();
const itemId = document.getElementById('editLibraryItemId').value;
const updatedData = {
name: document.getElementById('editLibraryName').value,
item_type: document.getElementById('editLibraryType').value,
isbn: document.getElementById('editLibraryIsbn').value,
code_4: document.getElementById('editLibraryCode4').value,
ort: document.getElementById('editLibraryLocation').value,
beschreibung: document.getElementById('editLibraryDescription').value
};
try {
const response = await fetch(`/api/library_item/${itemId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify(updatedData)
});
const result = await response.json();
if (response.ok && result.ok) {
alert(result.message || 'Medium erfolgreich aktualisiert!');
closeEditLibraryModal();
pagingState.loading = false;
loadLibraryItems();
} else {
alert(result.message || 'Fehler beim Speichern der Änderungen.');
}
} catch (error) {
console.error('Update failed:', error);
alert('Netzwerkfehler beim Aktualisieren des Mediums.');
}
});
}
});
// Modal Control Functions (accessible globally from table buttons)
function openEditLibraryItem(itemId) { function openEditLibraryItem(itemId) {
const item = getLibraryItemById(itemId); const item = libraryItems.find(i => i._id === itemId);
if (!item) { if (!item) {
alert('Element nicht gefunden.'); alert('Fehler: Das Medium konnte nicht gefunden werden.');
return; return;
} }
const modal = document.getElementById('editLibraryModal'); // Populate the form fields - Using correct German properties from MongoDB fields
document.getElementById('editLibraryItemId').value = item._id || ''; document.getElementById('editLibraryItemId').value = item._id || '';
document.getElementById('editLibraryName').value = item.Name || ''; document.getElementById('editLibraryName').value = item.Name || '';
document.getElementById('editLibraryAuthor').value = item.Autor || item.Author || ''; document.getElementById('editLibraryType').value = item.ItemType || 'book';
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
document.getElementById('editLibraryLocation').value = item.Ort || '';
document.getElementById('editLibraryIsbn').value = item.ISBN || ''; document.getElementById('editLibraryIsbn').value = item.ISBN || '';
document.getElementById('editLibraryCode4').value = item.Code_4 || ''; document.getElementById('editLibraryCode4').value = item.Code_4 || item.Code4 || '';
document.getElementById('editLibraryType').value = (item.ItemType || 'book'); document.getElementById('editLibraryLocation').value = item.Ort || '';
modal.style.display = 'flex'; document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
document.getElementById('editLibraryModal').style.display = 'flex';
} }
function closeEditLibraryModal() { function closeEditLibraryModal() {
const modal = document.getElementById('editLibraryModal'); document.getElementById('editLibraryModal').style.display = 'none';
modal.style.display = 'none';
} }
async function submitEditLibraryItem(event) {
event.preventDefault();
const itemId = document.getElementById('editLibraryItemId').value;
const payload = {
name: document.getElementById('editLibraryName').value,
autor: document.getElementById('editLibraryAuthor').value,
beschreibung: document.getElementById('editLibraryDescription').value,
ort: document.getElementById('editLibraryLocation').value,
isbn: document.getElementById('editLibraryIsbn').value,
code_4: document.getElementById('editLibraryCode4').value,
item_type: document.getElementById('editLibraryType').value
};
try {
const response = await fetch(`/api/library_item/${itemId}/update`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok || !result.ok) {
alert(result.message || 'Bearbeiten fehlgeschlagen.');
return;
}
closeEditLibraryModal();
await loadLibraryItems();
setScanStatus('Bibliotheksmedium aktualisiert.', 'ok');
} catch (err) {
console.error('Library edit failed:', err);
alert('Fehler beim Bearbeiten des Bibliotheksmediums.');
}
}
// Load on page init
document.addEventListener('DOMContentLoaded', () => {
loadLibraryItems();
wireScannerUi();
const editForm = document.getElementById('editLibraryForm');
if (editForm) {
editForm.addEventListener('submit', submitEditLibraryItem);
}
});
</script> </script>
<div id="editLibraryModal" class="modal" style="display:none;"> <div id="editLibraryModal" class="modal" style="display:none;">
@@ -1180,10 +1229,6 @@
<label for="editLibraryName">Titel</label> <label for="editLibraryName">Titel</label>
<input id="editLibraryName" required> <input id="editLibraryName" required>
</div> </div>
<div>
<label for="editLibraryAuthor">Autor/Künstler</label>
<input id="editLibraryAuthor">
</div>
<div> <div>
<label for="editLibraryType">Medientyp</label> <label for="editLibraryType">Medientyp</label>
<select id="editLibraryType"> <select id="editLibraryType">
@@ -1218,4 +1263,4 @@
</form> </form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+108 -58
View File
@@ -373,7 +373,7 @@
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Thema:</label> <label>Schlagwort:</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')"></button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')"></button>
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
</div> </div>
@@ -413,17 +413,14 @@
</div> </div>
<div class="qr-container"> <div class="qr-container">
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button> <button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
<div id="qr-reader" style="display: none;"> <div id="qr-reader"></div>
<video id="qr-reader-video" playsinline style="width: 100%; max-width: 500px; border-radius: 8px;"></video>
<canvas id="qr-reader-canvas" style="display: none;"></canvas>
</div>
</div> </div>
<div id="table-view-header" class="table-view-header" aria-hidden="true"> <div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span> <span>Name</span>
<span>Ort</span> <span>Ort</span>
<span>Unterrichtsfach</span> <span>Unterrichtsfach</span>
<span>Jahrgangsstufe</span> <span>Jahrgangsstufe</span>
<span>Thema</span> <span>Schlagwort</span>
<span>Barcode</span> <span>Barcode</span>
</div> </div>
<div id="items-container" class="items-container"> <div id="items-container" class="items-container">
@@ -528,9 +525,7 @@
window.isDebug = false; // Set to true only for development environment window.isDebug = false; // Set to true only for development environment
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script> <script>
// Global state // Global state
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null') const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
@@ -539,59 +534,114 @@
let codeSearchTerm = ''; let codeSearchTerm = '';
let descSearchIds = null; // Set of matching IDs or null when disabled let descSearchIds = null; // Set of matching IDs or null when disabled
// QR scanner toggle setup // Keep track of the scanner state globally/outer scope
document.addEventListener('DOMContentLoaded', function() { let isScanning = false;
const scanButton = document.getElementById('scanButton'); let activeScannerCallback = null; // Tracks which function currently owns the scanner output
function startScanner(targetCallback) {
const qrReader = document.getElementById('qr-reader'); const qrReader = document.getElementById('qr-reader');
let hybridScanner = null; const statusText = document.getElementById('status');
if (!scanButton || !qrReader) return; // Store the custom action that should happen when a barcode is found
activeScannerCallback = targetCallback;
const setScannerUi = (isOpen) => { // Show the container
scanButton.classList.toggle('is-active', isOpen); if (qrReader) qrReader.style.display = 'block';
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); if (statusText) statusText.innerText = "Initializing camera...";
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
};
scanButton.addEventListener('click', function() { Quagga.init({
if (qrReader.style.display !== 'block') { inputStream: {
qrReader.style.display = 'block'; name: "Live",
type: "LiveStream",
hybridScanner = new HybridScanner({ // Make sure this targets your correct HTML container element
videoId: 'qr-reader-video', target: document.querySelector('#scanner-container'),
canvasId: 'qr-reader-canvas', constraints: {
formats: ['QR_CODE', 'CODE_128', 'EAN_13', 'EAN_8', 'CODE_39'], width: 640,
fps: 10, height: 480,
facingMode: 'environment', facingMode: "environment" // Force rear camera
onSuccess: function(decodedText) { },
hybridScanner.stop(); },
qrReader.style.display = 'none'; decoder: {
// Your required formats mapped to Quagga2 reader strings
// Put scanned code into the search box and trigger search readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
const searchInput = document.getElementById('code-search'); }
if (searchInput) { }, function(err) {
searchInput.value = decodedText; if (err) {
searchByCode(); console.error("Initialization error:", err);
} if (statusText) statusText.innerText = "Camera Error";
return;
setScannerUi(false);
},
onError: function(error) {
console.error('Scanner error:', error);
}
});
hybridScanner.start();
setScannerUi(true);
} else {
if (hybridScanner) {
hybridScanner.stop();
hybridScanner = null;
}
qrReader.style.display = 'none';
setScannerUi(false);
} }
Quagga.start();
isScanning = true;
// If we are using the main search button, manage its UI state
if (!targetCallback) {
setScannerUi(true);
}
if (statusText) statusText.innerText = "Scanning...";
}); });
}
function stopScanner() {
Quagga.stop();
isScanning = false;
activeScannerCallback = null; // Reset the callback routing
const qrReader = document.getElementById('qr-reader');
if (qrReader) qrReader.style.display = 'none';
setScannerUi(false);
}
function setScannerUi(isOpen) {
const scanBtn = document.getElementById('scanButton');
if (!scanBtn) return;
scanBtn.classList.toggle('is-active', isOpen);
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
}
// Unified global reader callback
Quagga.onDetected(function(data) {
const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode);
// Keep a local reference to the active callback before shutting down the engine
const currentCallback = activeScannerCallback;
// Critical: Stop scanning immediately to prevent multiple triggers
stopScanner();
// Route data based on who opened the scanner
if (typeof currentCallback === "function") {
// Send data directly to the specific edit field's logic
currentCallback(barcode);
} else {
// Fallback Default: Main search box logic
const resultText = document.getElementById('barcode-result');
if (resultText) resultText.innerText = barcode;
const searchInput = document.getElementById('code-search');
if (searchInput) {
searchInput.value = barcode;
if (typeof searchByCode === "function") {
searchByCode();
}
}
}
});
// Event Listener for the main standalone search button
document.getElementById('scanButton')?.addEventListener('click', function() {
if (!isScanning) {
// Pass null so it defaults to filling the standard search box
startScanner(null);
} else {
stopScanner();
}
}); });
// Return preferred primary and fallback image URLs based on extension // Return preferred primary and fallback image URLs based on extension
@@ -1007,7 +1057,7 @@
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p> <p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p> <p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p> <p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p> <p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p> <p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p> <p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''} ${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
@@ -1632,7 +1682,7 @@
</div> </div>
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Thema:</div> <div class="detail-label">Schlagwort:</div>
<div class="detail-value">${filter3Array.join(', ') || '-'}</div> <div class="detail-value">${filter3Array.join(', ') || '-'}</div>
</div> </div>
+129 -80
View File
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
<label>Thema:</label> <label>Schlagwort:</label>
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')"></button> <button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')"></button>
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button> <button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
</div> </div>
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
<div class="qr-container"> <div class="qr-container">
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button> <button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
<div id="qr-reader"></div> <div id="qr-reader" style="display: none;">
<h3>Status: <span id="status">Bereit</span></h3>
<div id="scanner-container"></div>
</div>
</div> </div>
<div id="table-view-header" class="table-view-header" aria-hidden="true"> <div id="table-view-header" class="table-view-header" aria-hidden="true">
@@ -2443,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<span>Ort</span> <span>Ort</span>
<span>Unterrichtsfach</span> <span>Unterrichtsfach</span>
<span>Jahrgangsstufe</span> <span>Jahrgangsstufe</span>
<span>Thema</span> <span>Schlagwort</span>
<span>Barcode</span> <span>Barcode</span>
<span>Anzahl</span> <span>Anzahl</span>
</div> </div>
@@ -2564,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
</div> </div>
<h3>Thema:</h3> <h3>Schlagwort:</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="edit-filter3-1">Wert 1:</label> <label for="edit-filter3-1">Wert 1:</label>
@@ -2729,7 +2732,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
window.isDebug = false; // Set to true only for development environment window.isDebug = false; // Set to true only for development environment
</script> </script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script> <script>
// Function to check if a file is a video // Function to check if a file is a video
function isVideoFile(filename) { function isVideoFile(filename) {
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
// Initialize QR Code scanner and global variables // Initialize QR Code scanner and global variables
let html5QrcodeScanner = null;
let codeSearchTerm = ''; let codeSearchTerm = '';
let descSearchIds = null; // Set of matching IDs or null when disabled let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = ''; let currentUsername = '';
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
// No-op in production // No-op in production
return; return;
} }
document.getElementById('scanButton').addEventListener('click', function() {
const qrReader = document.getElementById('qr-reader');
const scanBtn = document.getElementById('scanButton');
const setScannerUi = (isOpen) => { // Keep track of the scanner state globally/outer scope
if (!scanBtn) return; let isScanning = false;
scanBtn.classList.toggle('is-active', isOpen); let activeScannerCallback = null; // Tracks which function currently owns the scanner output
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen'; function startScanner(targetCallback) {
}; const qrReader = document.getElementById('qr-reader');
const statusText = document.getElementById('status');
if (qrReader.style.display === 'none') {
qrReader.style.display = 'block'; // Store the custom action that should happen when a barcode is found
activeScannerCallback = targetCallback;
html5QrcodeScanner = new Html5QrcodeScanner(
"qr-reader", { // Show the container
fps: 10, if (qrReader) qrReader.style.display = 'block';
qrbox: 250, if (statusText) statusText.innerText = "Initializing camera...";
rememberLastUsedCamera: true
} Quagga.init({
); inputStream: {
name: "Live",
html5QrcodeScanner.render((decodedText) => { type: "LiveStream",
html5QrcodeScanner.clear(); // Make sure this targets your correct HTML container element
qrReader.style.display = 'none'; target: document.querySelector('#scanner-container'),
constraints: {
// Instead of navigating to the URL, put the scanned code in the search box width: 640,
const searchInput = document.getElementById('code-search'); height: 480,
if (searchInput) { facingMode: "environment" // Force rear camera
searchInput.value = decodedText; },
// Trigger search automatically },
decoder: {
// Your required formats mapped to Quagga2 reader strings
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
}
}, function(err) {
if (err) {
console.error("Initialization error:", err);
if (statusText) statusText.innerText = "Camera Error";
return;
}
Quagga.start();
isScanning = true;
// If we are using the main search button, manage its UI state
if (!targetCallback) {
setScannerUi(true);
}
if (statusText) statusText.innerText = "Scanning...";
});
}
function stopScanner() {
Quagga.stop();
isScanning = false;
activeScannerCallback = null; // Reset the callback routing
const qrReader = document.getElementById('qr-reader');
if (qrReader) qrReader.style.display = 'none';
setScannerUi(false);
}
function setScannerUi(isOpen) {
const scanBtn = document.getElementById('scanButton');
if (!scanBtn) return;
scanBtn.classList.toggle('is-active', isOpen);
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
}
// Unified global reader callback
Quagga.onDetected(function(data) {
const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode);
// Keep a local reference to the active callback before shutting down the engine
const currentCallback = activeScannerCallback;
// Critical: Stop scanning immediately to prevent multiple triggers
stopScanner();
// Route data based on who opened the scanner
if (typeof currentCallback === "function") {
// Send data directly to the specific edit field's logic
currentCallback(barcode);
} else {
// Fallback Default: Main search box logic
const resultText = document.getElementById('barcode-result');
if (resultText) resultText.innerText = barcode;
const searchInput = document.getElementById('code-search');
if (searchInput) {
searchInput.value = barcode;
if (typeof searchByCode === "function") {
searchByCode(); searchByCode();
} }
setScannerUi(false);
});
setScannerUi(true);
} else {
if (html5QrcodeScanner) {
html5QrcodeScanner.clear();
} }
qrReader.style.display = 'none'; }
setScannerUi(false); });
// Event Listener for the main standalone search button
document.getElementById('scanButton')?.addEventListener('click', function() {
if (!isScanning) {
// Pass null so it defaults to filling the standard search box
startScanner(null);
} else {
stopScanner();
} }
}); });
@@ -2824,26 +2889,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
return; return;
} }
if (qrReader.style.display !== 'none') { // Toggle close if it's already running
if (html5QrcodeScanner) { if (isScanning && qrReader.style.display !== 'none') {
html5QrcodeScanner.clear(); stopScanner();
}
qrReader.style.display = 'none';
scanEditBtn.textContent = 'Barcode scannen'; scanEditBtn.textContent = 'Barcode scannen';
return; return;
} }
qrReader.style.display = 'block';
scanEditBtn.textContent = 'Scanner schließen'; scanEditBtn.textContent = 'Scanner schließen';
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
fps: 10, // Start scanner with custom logic mapping to the Code input field
qrbox: 250, startScanner(function(decodedText) {
rememberLastUsedCamera: true editCodeInput.value = decodedText;
});
html5QrcodeScanner.render((decodedText) => {
html5QrcodeScanner.clear();
qrReader.style.display = 'none';
editCodeInput.value = String(decodedText || '').trim();
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null); validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
scanEditBtn.textContent = 'Barcode scannen'; scanEditBtn.textContent = 'Barcode scannen';
}); });
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
return; return;
} }
if (qrReader.style.display !== 'none') { // Toggle close if it's already running
if (html5QrcodeScanner) { if (isScanning && qrReader.style.display !== 'none') {
html5QrcodeScanner.clear(); stopScanner();
}
qrReader.style.display = 'none';
scanIsbnBtn.textContent = 'ISBN scannen'; scanIsbnBtn.textContent = 'ISBN scannen';
return; return;
} }
qrReader.style.display = 'block';
scanIsbnBtn.textContent = 'Scanner schließen'; scanIsbnBtn.textContent = 'Scanner schließen';
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
fps: 10, // Start scanner with custom logic mapping to the ISBN input field
qrbox: 250, startScanner(function(decodedText) {
rememberLastUsedCamera: true editIsbnInput.value = decodedText;
});
html5QrcodeScanner.render((decodedText) => {
html5QrcodeScanner.clear();
qrReader.style.display = 'none';
editIsbnInput.value = String(decodedText || '').trim();
scanIsbnBtn.textContent = 'ISBN scannen'; scanIsbnBtn.textContent = 'ISBN scannen';
if (typeof fetchBookInfo === 'function') { if (typeof fetchBookInfo === 'function') {
fetchBookInfo('edit'); fetchBookInfo('edit');
@@ -3637,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p> <p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p> <p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p> <p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p> <p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p> <p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p> <p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''} ${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
@@ -4180,7 +4229,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
document.getElementById('edit-filter2-3').value = filter2Array[2] || ''; document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
document.getElementById('edit-filter2-4').value = filter2Array[3] || ''; document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
// Fill in filter 3 (Thema) // Fill in filter 3 (Schlagwort)
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []); const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
document.getElementById('edit-filter3-1').value = filter3Array[0] || ''; document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
document.getElementById('edit-filter3-2').value = filter3Array[1] || ''; document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
@@ -4494,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
<div class="detail-group"> <div class="detail-group">
<div class="detail-label">Thema:</div> <div class="detail-label">Schlagwort:</div>
<div class="detail-value">${filter3Array.join(', ') || '-'}</div> <div class="detail-value">${filter3Array.join(', ') || '-'}</div>
</div> </div>
+18 -14
View File
@@ -14,13 +14,15 @@
<div class="card-body p-4 p-md-5 bg-white"> <div class="card-body p-4 p-md-5 bg-white">
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3"> <form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
<div class="row g-3"> <div class="row g-3">
<labal for="title" class="form-label fw-semibold">Titel des Terminplans</label>
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="start_date" class="form-label fw-semibold">Startdatum</label> <label for="start_date" class="form-label fw-semibold">Startdatum</label>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required> <input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="end_date" class="form-label fw-semibold">Enddatum</label> <label for="end_date" class="form-label fw-semibold">Enddatum</label>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required> <input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
</div> </div>
</div> </div>
@@ -48,7 +50,7 @@
<div class="mt-3"> <div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label> <label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea> <textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" ></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div> <div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
</div> </div>
</div> </div>
@@ -56,24 +58,26 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label> <label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required> <input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1">
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label> <label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required> <input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" >
</div> </div>
</div> </div>
<div> {% if mail_service_enabled %}
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label> <div>
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de"> <label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
</div> <input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
</div>
<div>
<label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
<div>
<label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
{% endif %}
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}> <input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label> <label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
-6
View File
@@ -26,12 +26,6 @@
</div> </div>
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button> <button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
</div> </div>
<div class="calendar-legend">
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Termine</span>
<span class="legend-item"><span class="legend-color planned"></span> Geplante Termine</span>
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Termine</span>
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Termine</span>
</div>
</div> </div>
<div class="calendar-options"> <div class="calendar-options">
+1
View File
@@ -71,6 +71,7 @@
<div class="border rounded-3 p-3"> <div class="border rounded-3 p-3">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3"> <div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div> <div>
<h3 class="h6 fw-bold mb-1">{{ event.title or "Terminplan" }}</h3>
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div> <div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
<div class="text-muted small">ID: {{ event.appointment_id }}</div> <div class="text-muted small">ID: {{ event.appointment_id }}</div>
{% if event.time_span %} {% if event.time_span %}
+323 -320
View File
@@ -155,6 +155,27 @@
background-color: #1e7e34; background-color: #1e7e34;
} }
/* Quagga Scanner Video Styles */
#code4-scanner video, #code4-scanner canvas,
#isbn-scanner video, #isbn-scanner canvas {
width: 100%;
max-width: 640px;
height: auto;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
#code4-scanner canvas, #isbn-scanner canvas {
position: absolute;
top: 0;
left: 0;
}
#code4-scanner, #isbn-scanner {
position: relative;
overflow: hidden;
}
/* ISBN input group styling */ /* ISBN input group styling */
.isbn-input-group { .isbn-input-group {
display: flex; display: flex;
@@ -831,7 +852,7 @@
<!-- Options will be loaded by JavaScript --> <!-- Options will be loaded by JavaScript -->
</select> </select>
</div> </div>
<div class="form-group"> <div class="formupload_admin-group">
<label for="filter2-4">Wert 4:</label> <label for="filter2-4">Wert 4:</label>
<select id="filter2-4" name="filter2" class="filter-dropdown-select"> <select id="filter2-4" name="filter2" class="filter-dropdown-select">
<option value="">-- Optional --</option> <option value="">-- Optional --</option>
@@ -840,7 +861,7 @@
</div> </div>
</div> </div>
<h3>Thema:</h3> <h3>Schlagwort:</h3>
<div class="multi-filter"> <div class="multi-filter">
<div class="form-group"> <div class="form-group">
<label for="filter3-1">Wert 1:</label> <label for="filter3-1">Wert 1:</label>
@@ -869,28 +890,41 @@
<label for="anschaffungskosten">Anschaffungskosten (€)</label> <label for="anschaffungskosten">Anschaffungskosten (€)</label>
<input id="anschaffungskosten" name="anschaffungskosten"> <input id="anschaffungskosten" name="anschaffungskosten">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="code_4">Code</label> <label for="scan_mode">Erfassungs-Modus:</label>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;"> <select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
<input id="code_4" name="code_4"> <option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button> <option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
</div> <option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small> </select>
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;">
<video id="code4-scanner-video" playsinline style="width: 100%; border-radius: 8px;"></video>
<canvas id="code4-scanner-canvas" style="display: none;"></canvas>
</div>
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small>
</div> </div>
<div class="form-group">
<label for="item_count">Anzahl dieser Sorte</label> <div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1"> <label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small> <div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
<span style="font-weight: bold;">bis</span>
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
</div>
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
</div> </div>
<div class="form-group">
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label> <div class="form-group" id="primary_code_group">
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea> <label for="code_4">Basis-Code</label>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small> <div style="display: flex; gap: 10px;">
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
</div>
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
<small id="code4-scan-status" class="form-text text-muted"></small>
</div>
<div class="form-group" id="individual_codes_group">
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B.&#10;ABC-001&#10;ABC-002" class="form-control"></textarea>
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
</div> </div>
<!-- Image upload (hidden for library mode) --> <!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}> <div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
@@ -910,10 +944,7 @@
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button> <button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button> <button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
</div> </div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"> <div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<video id="isbn-scanner-video" playsinline style="width: 100%; border-radius: 8px;"></video>
<canvas id="isbn-scanner-canvas" style="display: none;"></canvas>
</div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small> <small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
<div id="book-info-container" class="book-info-container"></div> <div id="book-info-container" class="book-info-container"></div>
</div> </div>
@@ -976,9 +1007,7 @@
document.getElementById('csv-help-modal').style.display = 'none'; document.getElementById('csv-help-modal').style.display = 'none';
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script> <script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }}; const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
@@ -991,12 +1020,12 @@
// Global variable to store fetched book data // Global variable to store fetched book data
let currentBookData = null; let currentBookData = null;
let code4ScannerInstance = null;
let code4ScannerRunning = false; // Quagga2 engine state management properties
let code4LastScanned = ''; let scannerRunning = false;
let code4LastScannedAt = 0; let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
let isbnScannerInstance = null; var code4LastScanned = '';
let isbnScannerRunning = false; var code4LastScannedAt = 0;
function setCode4ScanStatus(message, isError = false) { function setCode4ScanStatus(message, isError = false) {
const statusEl = document.getElementById('code4-scan-status'); const statusEl = document.getElementById('code4-scan-status');
@@ -1049,71 +1078,230 @@
statusEl.style.color = '#b00020'; statusEl.style.color = '#b00020';
} }
// Centered Quagga2 Engine Initialization Pattern
function runEngineInitialization(targetSelector, activeCallback, completionMsg, errorStatusSetter) {
if (scannerRunning) {
Quagga.stop();
scannerRunning = false;
}
activeScannerCallback = activeCallback;
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: document.querySelector(targetSelector),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Force rear camera lenses
},
},
decoder: {
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
}
}, function(err) {
if (err) {
console.error("Initialization error:", err);
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
return;
}
Quagga.start();
scannerRunning = true;
errorStatusSetter(completionMsg, false);
});
}
function killScannerHardware() {
if (!scannerRunning) return;
Quagga.stop();
scannerRunning = false;
activeScannerCallback = null;
}
// Unified tracking capture listener
Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return;
const rawBarcode = String(data.codeResult.code || '').trim();
const currentTargetCallback = activeScannerCallback;
if (typeof currentTargetCallback === "function") {
currentTargetCallback(rawBarcode);
}
});
// Globale Variablen für den Scanner-Status
var code4LastScanned = '';
var code4LastScannedAt = 0;
/**
* Schaltet das UI basierend auf dem ausgewählten Modus um
*/
function toggleScanMode() {
const mode = document.getElementById('scan_mode').value;
const rangeGroup = document.getElementById('range_generator_group');
const scanButton = document.getElementById('scan-code4-btn');
const scannerBox = document.getElementById('code4-scanner');
if (scannerBox && scannerBox.style.display !== 'none') {
if (typeof killScannerHardware === 'function') killScannerHardware();
scannerBox.style.display = 'none';
if (scanButton) scanButton.textContent = 'Barcode scannen';
}
if (mode === 'range') {
if(rangeGroup) rangeGroup.style.display = 'block';
if(scanButton) scanButton.style.display = 'none';
} else {
if(rangeGroup) rangeGroup.style.display = 'none'; // Hier wurde der Fehler korrigiert
if(scanButton) scanButton.style.display = 'inline-block';
}
}
/**
* Generiert eine Liste von Codes basierend auf den Eingaben
*/
function generateCodeRange() {
const prefix = document.getElementById('range_prefix').value.trim();
const startStr = document.getElementById('range_start').value.trim();
const endStr = document.getElementById('range_end').value.trim();
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
const paddingLength = endStr.length;
if (isNaN(start) || isNaN(end) || start > end) {
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
return;
}
let generatedCodes = [];
for (let i = start; i <= end; i++) {
let numStr = i.toString().padStart(paddingLength, '0');
generatedCodes.push(`${prefix}${numStr}`);
}
const codeField = document.getElementById('code_4');
const individualCodesArea = document.getElementById('individual_codes');
if (generatedCodes.length > 0) {
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
codeField.value = generatedCodes[0];
if (typeof validateCodeField === 'function') validateCodeField(codeField);
generatedCodes.shift();
}
if (generatedCodes.length > 0) {
let existing = individualCodesArea.value.trim();
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
}
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
}
}
/**
* Scanner-Funktion (Single & Continuous Modus)
*/
function startCode4Scanner() { function startCode4Scanner() {
const scannerBox = document.getElementById('code4-scanner'); const scannerBox = document.getElementById('code4-scanner');
const scanButton = document.getElementById('scan-code4-btn'); const scanButton = document.getElementById('scan-code4-btn');
const codeField = document.getElementById('code_4'); const codeField = document.getElementById('code_4');
const individualCodesArea = document.getElementById('individual_codes');
const scanMode = document.getElementById('scan_mode').value;
if (!scannerBox || !scanButton || !codeField) return; if (!scannerBox || !scanButton || !codeField) return;
// Stop ISBN scanner if currently running to avoid camera conflicts. // Beende evtl. laufenden ISBN Scanner
if (isbnScannerRunning && isbnScannerInstance) { const isbnScannerBox = document.getElementById('isbn-scanner');
isbnScannerInstance.stop(); if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
isbnScannerInstance = null; killScannerHardware();
const isbnScannerBox = document.getElementById('isbn-scanner'); isbnScannerBox.style.display = 'none';
const isbnScanButton = document.getElementById('scan-isbn-btn'); const isbnScanButton = document.getElementById('scan-isbn-btn');
if (isbnScannerBox) isbnScannerBox.style.display = 'none'; if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen'; if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
isbnScannerRunning = false;
setIsbnScanStatus('Scanner gestoppt.');
} }
if (code4ScannerRunning && code4ScannerInstance) { // Scanner ausschalten, wenn er bereits läuft
code4ScannerInstance.stop(); if (scannerBox.style.display !== 'none') {
code4ScannerInstance = null; killScannerHardware();
scannerBox.style.display = 'none'; scannerBox.style.display = 'none';
scanButton.textContent = 'Barcode scannen'; scanButton.textContent = 'Barcode scannen';
code4ScannerRunning = false; if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
setCode4ScanStatus('Scanner gestoppt.');
return; return;
} }
// Scanner starten
scannerBox.style.display = 'block'; scannerBox.style.display = 'block';
scanButton.textContent = 'Scanner stoppen'; scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
code4ScannerRunning = true; if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
code4ScannerInstance = new HybridScanner({ if (typeof runEngineInitialization === 'function') {
videoId: 'code4-scanner-video', runEngineInitialization(
canvasId: 'code4-scanner-canvas', '#code4-scanner',
formats: ['CODE_128', 'CODE_39', 'QR_CODE'], function(decodedText) {
fps: 10, const now = Date.now();
facingMode: 'environment', if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) {
onSuccess: function(decodedText) { return;
const scannedCode = String(decodedText || '').trim(); }
if (!scannedCode) return; code4LastScanned = decodedText;
code4LastScannedAt = now;
if (scanMode === 'single') {
killScannerHardware();
scannerBox.style.display = 'none';
scanButton.textContent = 'Barcode scannen';
codeField.value = decodedText;
if (typeof validateCodeField === 'function') validateCodeField(codeField);
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
const now = Date.now(); } else if (scanMode === 'continuous') {
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) { let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
return;
}
code4LastScanned = scannedCode;
code4LastScannedAt = now;
codeField.value = scannedCode; if (!codeField.value) {
validateCodeField(codeField); codeField.value = decodedText;
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`); if (typeof validateCodeField === 'function') validateCodeField(codeField);
}, if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Basis-Code gesetzt: ${decodedText}`);
onError: function(error) { } else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
setCode4ScanStatus(`Fehler: ${error}`, true); individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
} if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
});
code4ScannerInstance.start(); try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.type = 'sine';
oscillator.frequency.value = 800;
gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.1);
} catch (e) {
console.log("Audio feedback not supported");
}
} else {
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code ${decodedText} wurde bereits gescannt.`);
}
}
},
'Scanner läuft. Richte die Kamera auf den Barcode.',
setCode4ScanStatus
);
}
} }
/**
* ISBN Scanner-Funktion
*/
function startIsbnScanner() { function startIsbnScanner() {
if (!libraryModuleEnabled) { if (!libraryModuleEnabled) {
setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true); if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
return; return;
} }
const scannerBox = document.getElementById('isbn-scanner'); const scannerBox = document.getElementById('isbn-scanner');
@@ -1121,74 +1309,75 @@
const isbnField = document.getElementById('isbn'); const isbnField = document.getElementById('isbn');
if (!scannerBox || !scanButton || !isbnField) return; if (!scannerBox || !scanButton || !isbnField) return;
// Stop Code_4 scanner if currently running to avoid camera conflicts. const codeScannerBox = document.getElementById('code4-scanner');
if (code4ScannerRunning && code4ScannerInstance) { const codeScanButton = document.getElementById('scan-code4-btn');
code4ScannerInstance.stop(); if (codeScannerBox && codeScannerBox.style.display !== 'none') {
code4ScannerInstance = null; killScannerHardware();
const codeScannerBox = document.getElementById('code4-scanner'); codeScannerBox.style.display = 'none';
const codeScanButton = document.getElementById('scan-code4-btn'); codeScanButton.textContent = 'Barcode scannen';
if (codeScannerBox) codeScannerBox.style.display = 'none'; if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen';
code4ScannerRunning = false;
setCode4ScanStatus('Scanner gestoppt.');
} }
if (isbnScannerRunning && isbnScannerInstance) { if (scannerBox.style.display !== 'none') {
isbnScannerInstance.stop(); killScannerHardware();
isbnScannerInstance = null;
scannerBox.style.display = 'none'; scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen'; scanButton.textContent = 'Barcode scannen';
isbnScannerRunning = false; if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
setIsbnScanStatus('Scanner gestoppt.');
return; return;
} }
scannerBox.style.display = 'block'; scannerBox.style.display = 'block';
scanButton.textContent = 'Scanner stoppen'; scanButton.textContent = 'Scanner stoppen';
isbnScannerRunning = true;
setIsbnScanStatus('Scanner läuft. Bitte ISBN-Code erfassen.');
isbnScannerInstance = new HybridScanner({ runEngineInitialization(
videoId: 'isbn-scanner-video', '#isbn-scanner',
canvasId: 'isbn-scanner-canvas', function(decodedText) {
formats: ['ISBN', 'EAN_13', 'EAN_8', 'QR_CODE'], killScannerHardware();
fps: 10, scannerBox.style.display = 'none';
facingMode: 'environment', scanButton.textContent = 'Barcode scannen';
onSuccess: function(decodedText) {
const scannedCode = String(decodedText || '').trim();
if (!scannedCode) return;
const normalizedIsbn = normalizeIsbnClient(scannedCode); const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
isbnField.value = normalizedIsbn || scannedCode; isbnField.value = normalizedIsbn || decodedText;
updateIsbnLiveValidation(); if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
if (normalizedIsbn) {
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
} else {
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
}
// Stop scanner after successful scan if (normalizedIsbn) {
if (isbnScannerInstance) { if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
isbnScannerInstance.stop(); if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
isbnScannerInstance = null; } else {
} if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
scannerBox.style.display = 'none'; }
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
if (normalizedIsbn) {
// Automatically load book metadata after a valid ISBN scan.
fetchBookInfo('upload');
}
}, },
onError: function(error) { 'Scanner läuft. Bitte ISBN-Code erfassen.',
setIsbnScanStatus(`Fehler: ${error}`, true); setIsbnScanStatus
} );
});
isbnScannerInstance.start();
} }
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
document.addEventListener('DOMContentLoaded', function() {
// 1. Basis-Code Scanner Button aktivieren
const scanCode4Btn = document.getElementById('scan-code4-btn');
if (scanCode4Btn) {
scanCode4Btn.addEventListener('click', startCode4Scanner);
}
// 2. ISBN Scanner Button aktivieren
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
if (scanIsbnBtn) {
scanIsbnBtn.addEventListener('click', startIsbnScanner);
}
// 3. Dropdown-Modus Wechsel überwachen
const scanModeSelect = document.getElementById('scan_mode');
if (scanModeSelect) {
scanModeSelect.addEventListener('change', toggleScanMode);
}
// 4. ISBN Live-Validierung
const isbnInput = document.getElementById('isbn');
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
isbnInput.addEventListener('input', updateIsbnLiveValidation);
}
});
// Load predefined filter values for dropdowns // Load predefined filter values for dropdowns
function loadPredefinedFilterValues(filterNumber) { function loadPredefinedFilterValues(filterNumber) {
fetch(`/get_predefined_filter_values/${filterNumber}`) fetch(`/get_predefined_filter_values/${filterNumber}`)
@@ -1588,6 +1777,7 @@
}, 10000); }, 10000);
} }
// Exported function for use within parent layout fields
function validateCodeField(input, excludeId = null) { function validateCodeField(input, excludeId = null) {
const code = input.value.trim(); const code = input.value.trim();
@@ -1713,16 +1903,6 @@
}); });
} }
const scanCodeBtn = document.getElementById('scan-code4-btn');
if (scanCodeBtn) {
scanCodeBtn.addEventListener('click', startCode4Scanner);
}
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
if (scanIsbnBtn) {
scanIsbnBtn.addEventListener('click', startIsbnScanner);
}
const isbnField = document.getElementById('isbn'); const isbnField = document.getElementById('isbn');
if (isbnField) { if (isbnField) {
isbnField.addEventListener('input', updateIsbnLiveValidation); isbnField.addEventListener('input', updateIsbnLiveValidation);
@@ -1762,188 +1942,11 @@
if (typeof duplicateData.images === 'string') { if (typeof duplicateData.images === 'string') {
try { try {
duplicateData.images = JSON.parse(duplicateData.images); duplicateData.images = JSON.parse(duplicateData.images);
console.log("Parsed images from string:", duplicateData.images);
} catch (e) { } catch (e) {
console.warn("Failed to parse images string, using as single item:", duplicateData.images); console.error("Error parsing duplicate images array:", e);
duplicateData.images = [duplicateData.images];
} }
} }
console.log(`Final duplicateData.images (${Array.isArray(duplicateData.images) ? 'array' : typeof duplicateData.images}):`, duplicateData.images); console.log("Parsed duplicate images successfully.");
}
}
// Then check for sessionStorage data (takes priority)
const sessionData = sessionStorage.getItem('duplicateItemData');
if (sessionData) {
try {
duplicateData = JSON.parse(sessionData);
sessionStorage.removeItem('duplicateItemData'); // Clean up
console.log("Session storage duplicate data:", duplicateData);
} catch (e) {
console.error('Error parsing session duplicate data:', e);
}
}
// Function to prefill form with duplicate data
function prefillFormWithDuplicateData() {
if (!duplicateData) return;
// Fill basic fields
const nameField = document.getElementById('name');
if (nameField && duplicateData.name) nameField.value = duplicateData.name;
const descField = document.getElementById('beschreibung');
if (descField && duplicateData.description) descField.value = duplicateData.description;
const yearField = document.getElementById('anschaffungsjahr');
if (yearField && duplicateData.year) yearField.value = duplicateData.year;
const costField = document.getElementById('anschaffungskosten');
if (costField && duplicateData.cost) costField.value = duplicateData.cost;
// Fill location dropdown
const locationField = document.getElementById('ort');
if (locationField && duplicateData.location) {
// Wait for locations to load, then set value
setTimeout(() => {
locationField.value = duplicateData.location;
}, 100);
}
// Fill filter arrays - handle up to 4 values per filter type
setTimeout(() => {
// Filter 1 (Unterrichtsfach)
if (duplicateData.filter1 && Array.isArray(duplicateData.filter1)) {
for (let j = 1; j <= Math.min(4, duplicateData.filter1.length); j++) {
const filterField = document.getElementById(`filter1-${j}`);
if (filterField && duplicateData.filter1[j-1]) {
filterField.value = duplicateData.filter1[j-1];
}
}
}
// Filter 2 (Jahrgangsstufe)
if (duplicateData.filter2 && Array.isArray(duplicateData.filter2)) {
for (let j = 1; j <= Math.min(4, duplicateData.filter2.length); j++) {
const filterField = document.getElementById(`filter2-${j}`);
if (filterField && duplicateData.filter2[j-1]) {
filterField.value = duplicateData.filter2[j-1];
}
}
}
// Filter 3 (Thema) - text inputs
if (duplicateData.filter3 && Array.isArray(duplicateData.filter3)) {
for (let j = 1; j <= Math.min(4, duplicateData.filter3.length); j++) {
const filterField = document.getElementById(`filter3-${j}`);
if (filterField && duplicateData.filter3[j-1]) {
filterField.value = duplicateData.filter3[j-1];
}
}
}
}, 200); // Wait for filter options to load
// Handle images - add hidden inputs for duplicate images
if (duplicateData.images && duplicateData.images.length > 0) {
console.log("Duplicate images data:", duplicateData.images);
const form = document.querySelector('form[action*="upload_item"]');
if (form) {
// Add hidden field to indicate duplication
const isDuplicatingField = document.createElement('input');
isDuplicatingField.type = 'hidden';
isDuplicatingField.name = 'is_duplicating';
isDuplicatingField.value = 'true';
form.appendChild(isDuplicatingField);
// Add each image as a duplicate image
const images = duplicateData.images || [];
console.log(`Adding ${images.length} images as hidden fields`);
if (Array.isArray(images)) {
images.forEach((imageName, index) => {
if (imageName) {
console.log(`Adding duplicate image ${index}: ${imageName}`);
const imageField = document.createElement('input');
imageField.type = 'hidden';
imageField.name = 'duplicate_images';
imageField.value = imageName;
form.appendChild(imageField);
}
});
} else {
console.warn("Images is not an array:", images);
// Try to handle as single item if it's a string
if (typeof images === 'string' && images) {
console.log("Adding single image:", images);
const imageField = document.createElement('input');
imageField.type = 'hidden';
imageField.name = 'duplicate_images';
imageField.value = images;
form.appendChild(imageField);
}
}
// Show image preview
showDuplicateImagePreview(duplicateData.images);
}
}
}
// Function to show preview of duplicate images
function showDuplicateImagePreview(images) {
const previewContainer = document.getElementById('image-preview-container');
if (!previewContainer) return;
console.log("showDuplicateImagePreview called with:", images);
// Make sure images is treated as an array
if (!Array.isArray(images)) {
if (typeof images === 'string') {
// Try to parse if it's a JSON string
try {
images = JSON.parse(images);
} catch (e) {
// If parsing fails, wrap the single string in an array
images = [images];
}
} else {
console.error("Invalid images data:", images);
return;
}
}
previewContainer.innerHTML = '';
console.log(`Processing ${images.length} images for preview`);
images.forEach((imageName, index) => {
console.log(`Processing image ${index}:`, imageName);
const preview = document.createElement('div');
preview.className = 'image-preview-item';
preview.innerHTML = `
<img src="/uploads/${imageName}" alt="Duplicate Image ${index + 1}" class="duplicate-image-preview">
<div class="image-controls">
<span>Von Original übernommen</span>
<button type="button" onclick="removeDuplicateImage('${imageName}', this)" class="remove-duplicate-image-button">Entfernen</button>
</div>
`;
previewContainer.appendChild(preview);
});
}
// Function to remove a duplicate image
function removeDuplicateImage(imageName, button) {
// Remove the hidden input
const hiddenInputs = document.querySelectorAll('input[name="duplicate_images"]');
hiddenInputs.forEach(input => {
if (input.value === imageName) {
input.remove();
}
});
// Remove the preview
const previewItem = button.closest('.image-preview-item');
if (previewItem) {
previewItem.remove();
} }
} }
</script> </script>
+1
View File
@@ -213,6 +213,7 @@ existing['modules'] = {
'library': {'enabled': True}, 'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365}, 'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
'terminplan': {'enabled': True}, 'terminplan': {'enabled': True},
'mail': {'enabled': True},
} }
existing['trial'] = trial_config existing['trial'] = trial_config
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""
Small debug script to check tenant-aware lookup for an appointment id.
Usage: ./tools/debug_get_appointment.py <appointment_id> [tenant]
"""
import sys
from bson.objectid import ObjectId
from Web.modules.database.settings import MongoClient, MONGODB_HOST, MONGODB_PORT, MONGODB_DB
import Web.modules.database.termine as termine
def main():
if len(sys.argv) < 2:
print("Usage: debug_get_appointment.py <appointment_id> [tenant]")
sys.exit(2)
aid = sys.argv[1]
tenant = sys.argv[2] if len(sys.argv) > 2 else None
if tenant:
print(f"Looking up appointment {aid} for tenant {tenant}")
else:
print(f"Looking up appointment {aid} for default tenant")
try:
# Use the module helper directly
item = termine.get_item(aid)
if item:
print('Found with termini.get_item:')
print(item)
else:
print('termin.get_item returned None')
# Try explicit MongoClient + tenant DB resolution
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
try:
from Web.tenant import TenantContext, get_tenant_db
if tenant:
ctx = TenantContext()
ctx.tenant_id = tenant
db = ctx.get_database(client)
else:
db = get_tenant_db(client) if 'get_tenant_db' in dir() else client[MONGODB_DB]
doc = db['appointments'].find_one({'_id': ObjectId(aid)})
print('Direct DB find_one returned:')
print(doc)
finally:
client.close()
except Exception as e:
print('Error during debug lookup:', e)
if __name__ == '__main__':
main()