Redesign: clear active nav state, Shipping labels + Receipts get free-text/raw print, shared label repository
deploy / deploy (push) Successful in 10s

- Home page is now just the print server overview, no more unrelated
  network links (3D printers, Cockpit) left over from when this was a
  general landing page.
- Navbar shows which section is active; Brother's per-printer buttons
  now live under a dedicated Brother nav item instead of always-visible.
- Shipping labels: paste raw ZPL directly (no need to save to a file
  first), with a Labelary preview link alongside it.
- Receipts: paste already-formatted ESC/POS text directly, printed as-is.
- Both can save what they just printed into a shared label repository
  (lib/label_repository.rb, one JSON file per label under data/labels/)
  for reprinting later -- saving happens independently of whether the
  print itself succeeds, so a failed/offline printer doesn't lose the
  content.
This commit is contained in:
randy committed 2026-09-13 11:11:12 +02:00
1 parent afae57aff4
commit 66e2e7a5c0
8 files changed
+226 -53

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
.session_secret .session_secret
public/files/* public/files/*
!public/files/.gitkeep !public/files/.gitkeep
data/labels/*
!data/labels/.gitkeep
+74
View File
@@ -4,6 +4,7 @@ require "sinatra/reloader"
require 'tmpdir' require 'tmpdir'
require 'net/http' require 'net/http'
require_relative 'lib/receipt_builder' require_relative 'lib/receipt_builder'
require_relative 'lib/label_repository'
set :protection, :except => :frame_options set :protection, :except => :frame_options
set :environment, :production set :environment, :production
@@ -24,6 +25,8 @@ TEST_LABEL_PDF = File.join(File.dirname(__FILE__), 'test-label.pdf')
PRINTER_DEVICE_EPSON = '/dev/usb/lp_epson' # see /etc/udev/rules.d/99-epson.rules PRINTER_DEVICE_EPSON = '/dev/usb/lp_epson' # see /etc/udev/rules.d/99-epson.rules
LABEL_REPO = LabelRepository.new(File.join(File.dirname(__FILE__), 'data', 'labels'))
BROTHER_PRINTERS = { BROTHER_PRINTERS = {
'ql560' => { port: 8560, label: 'Brother QL-560' }, 'ql560' => { port: 8560, label: 'Brother QL-560' },
'ql700' => { port: 8700, label: 'Brother QL-700' }, 'ql700' => { port: 8700, label: 'Brother QL-700' },
@@ -104,6 +107,16 @@ helpers do
receipt.print!(PRINTER_DEVICE_EPSON) receipt.print!(PRINTER_DEVICE_EPSON)
end end
# Prints already-formatted ESC/POS text as-is (just the reset + code page
# prefix and a trailing cut) -- for content prepared elsewhere or reprinted
# from the label repository, unlike print_receipt's title/body template.
def print_raw_escpos(content)
receipt = ReceiptBuilder.new
receipt.align(:left).text(content)
receipt.feed(3).cut
receipt.print!(PRINTER_DEVICE_EPSON)
end
# Transparently reverse-proxies the current request to one of the # Transparently reverse-proxies the current request to one of the
# brother_ql_web instances, so all three printers (web UI and API alike) # brother_ql_web instances, so all three printers (web UI and API alike)
# are reachable through this single sinatra port instead of their own # are reachable through this single sinatra port instead of their own
@@ -153,9 +166,26 @@ get '/' do
end end
get '/upload' do get '/upload' do
@labels = LABEL_REPO.all(type: 'zpl')
erb :upload erb :upload
end end
post '/shipping/zpl' do
zpl = params[:zpl].to_s
if zpl.strip.empty?
flash 'ZPL text is empty', 'warning'
else
LABEL_REPO.save(name: params[:save_as], type: 'zpl', content: zpl) unless params[:save_as].to_s.empty?
begin
print_to_datamax(zpl)
flash 'ZPL sent to printer', 'success'
rescue => e
flash "ZPL print failed (#{e.message})", 'danger'
end
end
redirect '/upload'
end
post '/upload' do post '/upload' do
if params[:file] if params[:file]
@@ -204,6 +234,7 @@ post '/test-print' do
end end
get '/epson' do get '/epson' do
@labels = LABEL_REPO.all(type: 'escpos')
erb :epson erb :epson
end end
@@ -217,6 +248,49 @@ post '/epson/print' do
redirect '/epson' redirect '/epson'
end end
post '/epson/raw' do
content = params[:content].to_s
if content.strip.empty?
flash 'Receipt text is empty', 'warning'
else
LABEL_REPO.save(name: params[:save_as], type: 'escpos', content: content) unless params[:save_as].to_s.empty?
begin
print_raw_escpos(content)
flash 'Sent to Epson printer', 'success'
rescue => e
flash "Epson print failed (#{e.message})", 'danger'
end
end
redirect '/epson'
end
post '/labels/:id/print' do
label = LABEL_REPO.find(params[:id])
halt 404, 'unknown label' unless label
begin
case label['type']
when 'zpl'
print_to_datamax(label['content'])
when 'escpos'
print_raw_escpos(label['content'])
end
flash "Printed '#{label['name']}'", 'success'
rescue => e
flash "Print failed (#{e.message})", 'danger'
end
redirect_target = label['type'] == 'zpl' ? '/upload' : '/epson'
redirect redirect_target
end
post '/labels/:id/delete' do
label = LABEL_REPO.find(params[:id])
redirect_target = label && label['type'] == 'zpl' ? '/upload' : '/epson'
LABEL_REPO.delete(params[:id])
flash 'Label deleted', 'success'
redirect redirect_target
end
get '/brother' do get '/brother' do
@printers = BROTHER_PRINTERS @printers = BROTHER_PRINTERS
erb :brother_picker erb :brother_picker
View File
Whitespace-only changes.
+46
View File
@@ -0,0 +1,46 @@
require 'json'
require 'securerandom'
require 'fileutils'
# Simple file-backed store for reusable ZPL/ESC-POS label templates -- one
# JSON file per label under DIR, similar in spirit to brother_ql_web's own
# label repository. No per-printer targeting yet (type just says which print
# path to use); that can be added once labels can target any of the five
# printers via the Brother API.
class LabelRepository
def initialize(dir)
@dir = dir
FileUtils.mkdir_p(@dir)
end
# type: "zpl" or "escpos"
def all(type: nil)
Dir.glob(File.join(@dir, '*.json')).map { |f| JSON.parse(File.read(f)) }
.select { |l| type.nil? || l['type'] == type }
.sort_by { |l| l['name'].downcase }
end
def find(id)
path = path_for(id)
return nil unless File.exist?(path)
JSON.parse(File.read(path))
end
def save(name:, type:, content:)
id = SecureRandom.uuid
label = { 'id' => id, 'name' => name, 'type' => type, 'content' => content }
File.write(path_for(id), JSON.generate(label))
label
end
def delete(id)
File.delete(path_for(id)) if File.exist?(path_for(id))
end
private
def path_for(id)
File.join(@dir, "#{id}.json")
end
end
+14 -9
View File
@@ -7,21 +7,26 @@
<div class="collapse navbar-collapse" id="navbarNavDropdown"> <div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/">Home</a> <a class="nav-link <%= 'active' if request.path == '/' %>" href="/">Home</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/upload">Shipping labels</a> <a class="nav-link <%= 'active' if request.path.start_with?('/brother') %>" href="/brother">Brother</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/epson">Receipts</a> <a class="nav-link <%= 'active' if request.path == '/upload' %>" href="/upload">Shipping labels</a>
</li>
<li class="nav-item">
<a class="nav-link <%= 'active' if request.path == '/epson' %>" href="/epson">Receipts</a>
</li> </li>
</ul> </ul>
<span class="navbar-text me-2">Brother:</span> <% if request.path.start_with?('/brother') %>
<div class="d-flex gap-1"> <span class="navbar-text me-2">Printer:</span>
<% BROTHER_PRINTERS.each do |key, info| %> <div class="d-flex gap-1">
<a href="/brother/<%= key %>/" class="btn btn-sm <%= request.path.start_with?("/brother/#{key}/") ? 'btn-primary' : 'btn-outline-secondary' %>"><%= info[:label].sub('Brother ', '') %></a> <% BROTHER_PRINTERS.each do |key, info| %>
<% end %> <a href="/brother/<%= key %>/" class="btn btn-sm <%= request.path.start_with?("/brother/#{key}/") ? 'btn-primary' : 'btn-outline-secondary' %>"><%= info[:label].sub('Brother ', '') %></a>
</div> <% end %>
</div>
<% end %>
</div> </div>
</div> </div>
</nav> </nav>
+40
View File
@@ -1,3 +1,4 @@
<h5>Formatted receipt</h5>
<form action="/epson/print" method="POST"> <form action="/epson/print" method="POST">
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Title (bold, centered)</label> <label for="title" class="form-label">Title (bold, centered)</label>
@@ -13,3 +14,42 @@
</div> </div>
<button class="btn btn-secondary" type="submit">Print</button> <button class="btn btn-secondary" type="submit">Print</button>
</form> </form>
<hr class="mt-4">
<h5>Free text (already-formatted ESC/POS)</h5>
<p>Sent as-is (just reset + code page prefix and a trailing cut), no title/body template applied.</p>
<form action="/epson/raw" method="POST">
<div class="mb-3">
<textarea name="content" class="form-control" rows="8" style="font-family: monospace;"></textarea>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label for="save_as_escpos" class="form-label">Save as (optional)</label>
<input name="save_as" class="form-control" type="text" id="save_as_escpos" placeholder="receipt name" />
</div>
<div class="col-auto">
<button class="btn btn-secondary" type="submit">Print</button>
</div>
</div>
</form>
<% if @labels && !@labels.empty? %>
<hr class="mt-4">
<h5>Saved receipts</h5>
<div class="list-group">
<% @labels.each do |label| %>
<div class="list-group-item d-flex justify-content-between align-items-center">
<%= label['name'] %>
<div class="d-flex gap-2">
<form action="/labels/<%= label['id'] %>/print" method="POST">
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
</form>
<form action="/labels/<%= label['id'] %>/delete" method="POST">
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
</form>
</div>
</div>
<% end %>
</div>
<% end %>
+8 -44
View File
@@ -1,47 +1,11 @@
<div class="card" > <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Brother Label Designer</h5> <h5 class="card-title">Print server</h5>
<p class="card-text">A web service to print labels on Brother QL label printers.</p> <p class="card-text">Connected printers:</p>
<a href="http://rpi-printserver.local:8560/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-560</a> <ul>
<a href="http://rpi-printserver.local:8700/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-700</a> <li>Datamax-ONeil E4204B Mark III (ZPL, 100x150) -- <a href="/upload">Shipping labels</a></li>
<a href="http://rpi-printserver.local:9100/labeldesigner" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">QL-1100</a> <li>Epson TM-T20II (ESC/POS) -- <a href="/epson">Receipts</a></li>
</div> <li>Brother QL-560, QL-700, QL-1100 -- <a href="/brother">Brother</a></li>
</div> </ul>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Cockpit</h5>
<p class="card-text">Cockpit is a web-based graphical interface for servers, intended for everyone</p>
<a href="https://rpi-printserver.local:9090" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">ZPL Viewer</h5>
<p class="card-text">An useful tool for labels in ZPL format.</p>
<a href="https://labelary.com/viewer.html" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Go</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Creality Sonic Pad</h5>
<p class="card-text">3D Printer</p>
<a href="http://spad-9088.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Fluidd</a>
<a href="http://spad-9088.local:8819" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
<a href="http://spad-9088.local:7125" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Moonraker</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Bigtreetech Pad 7 CNC</h5>
<p class="card-text">CNC</p>
<a href="http://btt-pad7-cnc.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
<a href="http://btt-pad7-cnc.local:7125" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer">Moonraker</a>
</div>
</div>
<div class="card" >
<div class="card-body">
<h5 class="card-title">Bigtreetech Pad 7 CAN</h5>
<p class="card-text">CAN</p>
<a href="http://rpi-btt-pad7-can.local" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Mainsail</a>
</div> </div>
</div> </div>
+42
View File
@@ -9,3 +9,45 @@
<form action="/test-print" method="POST" class="mt-3"> <form action="/test-print" method="POST" class="mt-3">
<button class="btn btn-outline-secondary" type="submit">Print test label</button> <button class="btn btn-outline-secondary" type="submit">Print test label</button>
</form> </form>
<hr class="mt-4">
<h5>Free text ZPL</h5>
<p>
<a href="https://labelary.com/viewer.html" target="_blank" rel="noopener noreferrer">Labelary ZPL viewer</a>
(preview/build ZPL before pasting it here)
</p>
<form action="/shipping/zpl" method="POST">
<div class="mb-3">
<textarea name="zpl" class="form-control" rows="8" placeholder="^XA...^XZ" style="font-family: monospace;"></textarea>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label for="save_as_zpl" class="form-label">Save as (optional)</label>
<input name="save_as" class="form-control" type="text" id="save_as_zpl" placeholder="label name" />
</div>
<div class="col-auto">
<button class="btn btn-secondary" type="submit">Print</button>
</div>
</div>
</form>
<% if @labels && !@labels.empty? %>
<hr class="mt-4">
<h5>Saved labels</h5>
<div class="list-group">
<% @labels.each do |label| %>
<div class="list-group-item d-flex justify-content-between align-items-center">
<%= label['name'] %>
<div class="d-flex gap-2">
<form action="/labels/<%= label['id'] %>/print" method="POST">
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
</form>
<form action="/labels/<%= label['id'] %>/delete" method="POST">
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
</form>
</div>
</div>
<% end %>
</div>
<% end %>