This commit is contained in:
Tim Lappe 2025-04-24 20:40:14 +02:00
parent 52a2ae9a31
commit d563f7244d
11 changed files with 510 additions and 44 deletions

View File

@ -1,3 +1,17 @@
module github.com/scanner/backend
go 1.20
go 1.22
toolchain go1.23.7
require (
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pdfcpu/pdfcpu v0.9.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/image v0.21.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

23
backend/go.sum Normal file
View File

@ -0,0 +1,23 @@
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
github.com/jung-kurt/gofpdf/v2 v2.0.0/go.mod h1:59m4YstrsXqnwOx8NOVIk6k0XqfrRPkiunj4eAevyaY=
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao=
github.com/pdfcpu/pdfcpu v0.9.1/go.mod h1:fVfOloBzs2+W2VJCCbq60XIxc3yJHAZ0Gahv1oO0gyI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -0,0 +1,57 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/scanner/backend/middleware"
"github.com/scanner/backend/models"
"github.com/scanner/backend/services"
)
type FileHandler struct {
fileConverter *services.FileConverter
}
func NewFileHandler(fileConverter *services.FileConverter) *FileHandler {
return &FileHandler{fileConverter: fileConverter}
}
func (h *FileHandler) MergeFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
middleware.ApplyCors(w, r)
var mergeRequest models.MergeRequest
err := json.NewDecoder(r.Body).Decode(&mergeRequest)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if len(mergeRequest.Filenames) < 2 {
http.Error(w, "At least two files are required for merging", http.StatusBadRequest)
return
}
files := []string{}
for _, filename := range mergeRequest.Filenames {
files = append(files, "/home/backend/var/documents/"+filename)
}
mergedFilename, err := h.fileConverter.MergeFiles(files)
if err != nil {
http.Error(w, "Failed to merge files: "+err.Error(), http.StatusInternalServerError)
return
}
response := map[string]string{
"mergedFile": "/files/" + mergedFilename,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@ -12,9 +12,11 @@ import (
func main() {
// Initialize services
scannerService := services.NewScannerService()
fileConverter := services.NewFileConverter()
// Initialize handlers
scanHandler := handlers.NewScanHandler(scannerService)
fileHandler := handlers.NewFileHandler(fileConverter)
// Set up routes with middleware
mux := http.NewServeMux()
@ -24,6 +26,7 @@ func main() {
mux.HandleFunc("/scan/abort", scanHandler.AbortScan)
mux.HandleFunc("/scan/status", scanHandler.GetScanStatus)
mux.HandleFunc("/documents", scanHandler.GetDocuments)
mux.HandleFunc("/documents/merge", fileHandler.MergeFiles)
fileServer := http.FileServer(http.Dir("/home/backend/var/documents"))
mux.Handle("/files/", http.StripPrefix("/files", fileServer))

View File

@ -1,7 +1,10 @@
package models
// Document represents a scanned document
type Document struct {
Filename string `json:"filename"`
URL string `json:"url"`
}
type MergeRequest struct {
Filenames []string `json:"filenames"`
}

View File

@ -0,0 +1,37 @@
package services
import (
"os"
"path/filepath"
"time"
"github.com/pdfcpu/pdfcpu/pkg/api"
)
type FileConverter struct {
}
func NewFileConverter() *FileConverter {
return &FileConverter{}
}
func (c *FileConverter) MergeFiles(filenames []string) (string, error) {
timestamp := time.Now().Format("20060102-150405")
mergedFilename := "merged-" + timestamp + ".pdf"
mergedFile := "/home/backend/var/documents/" + mergedFilename
outputDir := filepath.Dir(mergedFile)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", err
}
_ = os.Remove(mergedFile)
config := api.LoadConfiguration()
if err := api.MergeCreateFile(filenames, mergedFile, false, config); err != nil {
return "", err
}
return mergedFilename, nil
}

View File

@ -42,7 +42,7 @@ func listDevices() ([]string, error) {
}
func startScan(outputFile string) (string, error) {
result, err := scanImage("-d", "escl:https://printer:443", "--format", "png", "--output-file", outputFile)
result, err := scanImage("-d", "escl:https://printer:443", "--format", "pdf", "--output-file", outputFile)
if err != nil {
return "", fmt.Errorf("error scanning: %v", err)
}

View File

@ -9,12 +9,10 @@ import (
"github.com/scanner/backend/models"
)
// ScannerService handles scanner operations
type ScannerService struct {
state models.ScannerState
}
// NewScannerService creates a new scanner service
func NewScannerService() *ScannerService {
return &ScannerService{
state: models.ScannerState{
@ -23,7 +21,6 @@ func NewScannerService() *ScannerService {
}
}
// GetScanStatus returns the current scan status
func (s *ScannerService) IsScanning() bool {
s.state.Mu.Lock()
state := s.state.IsScanning
@ -32,7 +29,6 @@ func (s *ScannerService) IsScanning() bool {
return state
}
// StartScan begins a new scanning process
func (s *ScannerService) StartScan() (bool, error) {
s.state.Mu.Lock()
defer s.state.Mu.Unlock()
@ -47,7 +43,6 @@ func (s *ScannerService) StartScan() (bool, error) {
return true, nil
}
// AbortScan stops an in-progress scan
func (s *ScannerService) AbortScan() (bool, error) {
s.state.Mu.Lock()
defer s.state.Mu.Unlock()
@ -60,7 +55,6 @@ func (s *ScannerService) AbortScan() (bool, error) {
return true, nil
}
// GetDocuments returns all scanned documents
func (s *ScannerService) GetDocuments() []models.Document {
const outputDir = "/home/backend/var/documents"
@ -76,7 +70,7 @@ func (s *ScannerService) GetDocuments() []models.Document {
}
filename := file.Name()
if !strings.HasPrefix(filename, "scan_") || !strings.HasSuffix(filename, ".jpg") {
if !strings.HasSuffix(filename, ".pdf") {
continue
}
@ -100,7 +94,7 @@ func (s *ScannerService) scan() {
outputDir := "/home/backend/var/documents"
timestamp := time.Now().Format("20060102150405")
outputFile := fmt.Sprintf("%s/scan_%s.jpg", outputDir, timestamp)
outputFile := fmt.Sprintf("%s/scan_%s.pdf", outputDir, timestamp)
_, err := startScan(outputFile)

32
docker-compose.server.yml Normal file
View File

@ -0,0 +1,32 @@
services:
backend:
hostname: scanner-backend
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8089:80"
extra_hosts:
- "printer:192.168.178.36"
networks:
- printer_network
- proxy
cap_add:
- NET_ADMIN
frontend:
hostname: scanner-frontend
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8090:80"
networks:
- printer_network
- proxy
networks:
printer_network:
driver: bridge
proxy:
external: true

View File

@ -5,76 +5,251 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Scanner</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
:root {
--primary-color: #4285f4;
--primary-hover: #3367d6;
--light-gray: #f5f5f5;
--border-color: #e0e0e0;
--shadow: 0 4px 6px rgba(0,0,0,0.1);
--radius: 8px;
--spacing: 24px;
}
body {
font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 30px;
background-color: #fafafa;
color: #333;
line-height: 1.6;
}
h1, h2 {
color: #1a1a1a;
font-weight: 600;
}
h1 {
font-size: 2.5rem;
margin-bottom: var(--spacing);
border-bottom: 2px solid var(--primary-color);
padding-bottom: 10px;
}
h2 {
font-size: 1.8rem;
margin-top: var(--spacing);
margin-bottom: calc(var(--spacing) / 2);
}
.container {
display: flex;
flex-direction: column;
gap: 20px;
gap: var(--spacing);
background-color: white;
border-radius: var(--radius);
padding: var(--spacing);
box-shadow: var(--shadow);
}
.controls {
display: flex;
gap: 10px;
gap: 15px;
}
button {
padding: 10px 20px;
padding: 12px 24px;
border: none;
border-radius: 4px;
background-color: #4285f4;
border-radius: var(--radius);
background-color: var(--primary-color);
color: white;
cursor: pointer;
transition: background-color 0.3s;
transition: all 0.3s ease;
font-weight: 500;
font-size: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
button:hover {
background-color: #3367d6;
background-color: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
button:active {
transform: translateY(0);
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.document-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.document-card {
border: 1px solid #ddd;
border-radius: 8px;
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: var(--shadow);
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
}
.document-card img {
.document-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0,0,0,0.15);
}
.document-card .pdf-thumbnail {
width: 100%;
height: 150px;
object-fit: cover;
height: 180px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8f9fa;
border-bottom: 1px solid var(--border-color);
}
.document-card .pdf-icon {
font-size: 60px;
color: #e74c3c;
}
.document-card .info {
padding: 10px;
padding: 15px;
background-color: white;
}
.document-card h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-container {
position: absolute;
top: 10px;
left: 10px;
z-index: 5;
}
.merge-controls {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.status {
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
padding: 15px;
border-radius: var(--radius);
font-weight: 500;
display: flex;
align-items: center;
position: relative;
}
.status::before {
content: '';
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
}
.status.scanning {
background-color: #e6f7ff;
border: 1px solid #91d5ff;
color: #0050b3;
}
.status.scanning::before {
background-color: #1890ff;
animation: pulse 1.5s infinite;
}
.status.idle {
background-color: #f6ffed;
border: 1px solid #b7eb8f;
color: #389e0d;
}
.status.idle::before {
background-color: #52c41a;
}
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.lightbox.open {
display: flex;
}
.lightbox-content {
width: 90%;
height: 90%;
position: relative;
}
.lightbox-content iframe {
width: 100%;
height: 100%;
border: none;
background-color: white;
}
.close-lightbox {
position: absolute;
top: -40px;
right: 0;
color: white;
font-size: 30px;
cursor: pointer;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@media (max-width: 768px) {
body {
padding: 15px;
}
.document-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
}
</style>
</head>
<body>
<div class="container">
<h1>Document Scanner</h1>
<div id="status" class="status idle">Scanner is idle</div>
<div class="container">
<div id="status" class="status idle">Scanner ist bereit</div>
<div class="controls">
<button id="startBtn">Start Scanning</button>
@ -82,17 +257,33 @@
</div>
<h2>Documents</h2>
<div class="merge-controls">
<button id="mergeSelectedBtn" disabled>Merge Selected</button>
<button id="mergeAllBtn" disabled>Merge All</button>
</div>
<div id="documents" class="document-grid">
<p>No documents scanned yet.</p>
</div>
</div>
<div id="pdfLightbox" class="lightbox">
<div class="lightbox-content">
<span class="close-lightbox">&times;</span>
<iframe id="pdfViewer"></iframe>
</div>
</div>
<script>
const API_URL = `/api`;
const startBtn = document.getElementById('startBtn');
const abortBtn = document.getElementById('abortBtn');
const mergeSelectedBtn = document.getElementById('mergeSelectedBtn');
const mergeAllBtn = document.getElementById('mergeAllBtn');
const statusEl = document.getElementById('status');
const documentsEl = document.getElementById('documents');
const pdfLightbox = document.getElementById('pdfLightbox');
const pdfViewer = document.getElementById('pdfViewer');
const closeLightbox = document.querySelector('.close-lightbox');
function setScannerState(scanning) {
startBtn.disabled = scanning;
@ -134,28 +325,136 @@
}
}
async function mergeDocuments(filenames) {
try {
const response = await fetch(`${API_URL}/documents/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filenames })
});
if (!response.ok) {
throw new Error('Failed to merge documents');
}
const result = await response.json();
if (result.mergedFile) {
openPdfViewer(result.mergedFile);
}
} catch (error) {
console.error('Error merging documents:', error);
alert('Failed to merge documents.');
}
}
function mergeSelected() {
const selectedCheckboxes = document.querySelectorAll('.document-checkbox:checked');
if (selectedCheckboxes.length < 2) {
alert('Please select at least 2 documents to merge.');
return;
}
const filenames = Array.from(selectedCheckboxes).map(checkbox =>
checkbox.closest('.document-card').getAttribute('data-filename')
);
mergeDocuments(filenames);
}
function mergeAll() {
const allDocumentCards = document.querySelectorAll('.document-card');
if (allDocumentCards.length < 2) {
alert('Need at least 2 documents to merge.');
return;
}
const filenames = Array.from(allDocumentCards).map(card =>
card.getAttribute('data-filename')
);
mergeDocuments(filenames);
}
function updateMergeButtons() {
const documentCount = document.querySelectorAll('.document-card').length;
mergeAllBtn.disabled = documentCount < 2;
const selectedCount = document.querySelectorAll('.document-checkbox:checked').length;
mergeSelectedBtn.disabled = selectedCount < 2;
}
function openPdfViewer(pdfUrl) {
pdfViewer.src = pdfUrl;
pdfLightbox.classList.add('open');
}
function closePdfViewer() {
pdfLightbox.classList.remove('open');
pdfViewer.src = '';
}
function handleDocumentClick(event) {
const checkbox = event.target.closest('.document-checkbox');
if (checkbox) {
updateMergeButtons();
return;
}
const card = event.target.closest('.document-card');
if (card) {
const pdfUrl = card.getAttribute('data-url');
openPdfViewer(pdfUrl);
}
}
function pollDocuments() {
fetch(`${API_URL}/documents`)
.then(response => response.json())
.then(documents => {
if (documents.length === 0) {
documentsEl.innerHTML = '<p>No documents scanned yet.</p>';
mergeAllBtn.disabled = true;
mergeSelectedBtn.disabled = true;
setTimeout(() => {
pollDocuments();
}, 2000);
return;
}
const existingCards = Array.from(documentsEl.querySelectorAll('.document-card')) || [];
const existingFilenames = existingCards.map(card =>
card.getAttribute('data-filename')
);
if (existingFilenames.length !== documents.length ||
!documents.every(doc => existingFilenames.includes(doc.filename))) {
documentsEl.innerHTML = '';
} else {
setTimeout(() => {
pollDocuments();
}, 2000);
return;
}
documentsEl.innerHTML = documents.map(doc => `
<div class="document-card">
<img src="${doc.url}" alt="${doc.filename}">
<div class="document-card" data-filename="${doc.filename}" data-url="${doc.url}">
<div class="checkbox-container">
<input type="checkbox" class="document-checkbox">
</div>
<div class="pdf-thumbnail">
<div class="pdf-icon">PDF</div>
</div>
<div class="info">
<h3>${doc.filename}</h3>
</div>
</div>
`).join('');
updateMergeButtons();
setTimeout(() => {
pollDocuments();
}, 2000);
@ -180,9 +479,13 @@
startBtn.addEventListener('click', startScanning);
abortBtn.addEventListener('click', abortScanning);
mergeSelectedBtn.addEventListener('click', mergeSelected);
mergeAllBtn.addEventListener('click', mergeAll);
documentsEl.addEventListener('click', handleDocumentClick);
closeLightbox.addEventListener('click', closePdfViewer);
pollStatus();
pollDocuments();
</script>
</body>
</html>
</html>

View File

@ -8,7 +8,7 @@ server {
}
location /api/ {
proxy_pass http://backend:80/;
proxy_pass http://scanner-backend:80/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;