Compare commits

...
12 Commits
Author SHA1 Message Date
randy fc59cf595e Add a friendly 502/503/504 page (nginx serves it directly, no backend needed)
deploy / deploy (push) Successful in 9s
2026-09-15 22:31:08 +02:00
randy 7ea03e5bc5 Rename /labels route to /repository
deploy / deploy (push) Successful in 9s
2026-09-15 22:29:10 +02:00
randy 61d190ed91 Move behind nginx reverse proxy: app binds 127.0.0.1:4567, nginx owns :80
deploy / deploy (push) Successful in 11s
Avoids the cap_net_bind_service dance breaking on every RVM ruby patch
upgrade (it happened today after today's ruby-4.0.7 install).
2026-09-15 22:24:40 +02:00
randy 46fd345b7f Match klikatko/mc-panel look: dark navbar, Bootstrap 5.3.8, MDI icons
deploy / deploy (push) Successful in 12s
2026-09-15 22:17:49 +02:00
randy 4308850a2e Test page: replace inverted section banners with plain '=' rules, frame page with '-'
deploy / deploy (push) Successful in 10s
Matches the style settled on for the print-issues receipt -- '=' for
labeled section breaks, '-' for the plain top/bottom frame, no more
fully blacked-out rows.
2026-09-13 12:40:00 +02:00
randy a58d4b66ea Update README: Epson routes, API usage example with the home-gitops print-issues workflow
deploy / deploy (push) Successful in 9s
2026-09-13 12:35:24 +02:00
randy ec9278c0dd Remove doubled-up padding on the divider links, add explicit flex align to the brand row
deploy / deploy (push) Successful in 9s
ps-3 stacked on top of nav-link's own padding made the gap after the
border look uneven -- nav-link's built-in padding is enough on its own.
2026-09-13 12:20:45 +02:00
randy f351212395 Fix navbar dividers: border-start on the link itself instead of a standalone vr div
deploy / deploy (push) Successful in 10s
The vr utility needs stretch alignment from its flex parent to size
correctly -- inside a plain nav-item li it collapsed/misaligned instead
of showing as a clean vertical rule.
2026-09-13 12:18:00 +02:00
randy eafea9f728 Add visual separators: inverted banner sections on receipts, dividers in navbar
deploy / deploy (push) Successful in 9s
ReceiptBuilder#invert (GS B) + #section_separator give the test page (and
future receipts) a full-width white-on-black banner between sections --
the closest a direct-thermal printer gets to a second color. Navbar gets
a vr divider between Home | Brother/Shipping labels/Receipts | Labels to
group the three logical clusters.
2026-09-13 12:16:09 +02:00
randy f8857384fd Shorten default barcode height -- printer default (~162 dots) prints noticeably tall
deploy / deploy (push) Successful in 10s
2026-09-13 12:12:28 +02:00
randy d2900361b3 Add Epson test page: text sizes, EAN-13, QR, and a raster image
deploy / deploy (push) Successful in 9s
ReceiptBuilder#image implements GS v 0 raster printing (no image library
needed for the demo -- a procedural 8px checkerboard proves it end to
end). Skipped attempting the printer's real-time clock: unsure the
TM-T20II actually has a battery-backed RTC (that's usually an option on
higher-end models), not worth guessing at an undocumented command.
2026-09-13 12:09:52 +02:00
randy 790d716d57 Fix ESC/POS notation parser: '#' only starts a comment as its own token
deploy / deploy (push) Successful in 9s
Stripping from the first '#' anywhere in the line ate content like '#5
Fix thing' (an issue reference) entirely, since it starts with '#' with
no preceding whitespace. Comments now only trigger on a standalone '#'
token (i.e. whitespace on both sides), matching the documented examples.
2026-09-13 11:51:36 +02:00
11 changed files with 226 additions and 45 deletions

No files matched your search

+31 -6
View File
@@ -1,18 +1,43 @@
= labelprint
Unified UI for printing labels/stickers:
Unified UI for printing labels/receipts:
* Native ZPL printing to a Datamax-ONeil thermal barcode printer
* A proxy/switcher in front of three https://github.com/DL6ER/brother_ql_web[brother_ql_web] instances, one per Brother QL printer
* ESC/POS receipt printing to an Epson TM-T20II
== Routes
* `/` -- upload a PDF and print it (ZPL)
* `/upload` -- upload form
* `/test-print` -- print the bundled test label
* `/` -- home page, links to everything below
* `/upload`, `POST /upload` -- upload a PDF/ZPL file and print it (Datamax)
* `POST /shipping/zpl` -- print raw ZPL text (see "API usage" below)
* `/test-print` -- print the bundled Datamax test label
* `/epson` -- receipt printing UI (title/body/QR template, or raw ESC/POS mnemonic notation)
* `POST /epson/print` -- print a title/body/QR receipt
* `POST /epson/raw` -- print already-formatted ESC/POS mnemonic notation (see "API usage" below)
* `POST /epson/test-print` -- print an Epson capability test page (text sizes, barcode, QR, raster image)
* `/labels` -- shared repository of saved ZPL labels / ESC-POS receipts (list, edit, preview, print, delete)
* `/brother` -- list of Brother QL printers
* `/brother/<printer>/*` -- proxied to that printer's brother_ql_web instance (UI and API alike)
== API usage (e.g. from Gitea Actions)
`POST /shipping/zpl` and `POST /epson/raw` are plain, unauthenticated form-POST endpoints -- no session/login needed, and printing without a `save_as`/`commit` param just prints (nothing gets kept in the label repository). That makes labelprint usable as a "dumb" print API for other automation: it doesn't need to know anything about the caller, and the caller doesn't need any labelprint-specific client, just an HTTP form POST.
Example: `randy/home-gitops` has a workflow (`.gitea/workflows/print-issues.yaml` + `scripts/print_issues.rb`) that fetches its own open issues via the Gitea API and prints a summary on the Epson receipt printer -- the Gitea-specific logic (fetching, formatting) lives entirely in that script, not here:
[source,ruby]
----
require 'net/http'
res = Net::HTTP.post_form(
URI('http://rpi-printserver.hv.priesol.net/epson/raw'),
'content' => "ESC E 0x01\nHello from CI\nESC E 0x00\nLF"
)
----
`content` uses labelprint's mnemonic ESC/POS notation: `ESC`/`GS`/`FS`/`LF`/`CR`/`HT`/`NUL` as command names, `0xNN` as a raw byte, anything else as literal text (CP852-encoded) -- see the `/epson` page for the full reference and worked examples (alignment, bold, barcodes). `POST /shipping/zpl` works the same way but with a `zpl` form field taking plain ZPL instead.
== Running
[source,shell]
@@ -25,9 +50,9 @@ Requires `config.json` (see printer definitions inside) and a `.session_secret`
== Deployment
Runs as `sinatra.service` (systemd) on `rpi-printserver.local`, under RVM (`rvm default do bundle exec ruby app.rb` -- always uses whatever ruby is currently the RVM default, no manual bumping needed).
Runs as `sinatra.service` (systemd) on `rpi-printserver.hv.priesol.net`, under RVM (`rvm default do bundle exec ruby app.rb` -- always uses whatever ruby is currently the RVM default, no manual bumping needed).
`.gitea/workflows/deploy.yaml` deploys on every push to `main`: SSH into `rpi-printserver.local`, `git pull`, `bundle install`, restart the service.
`.gitea/workflows/deploy.yaml` deploys on every push to `main`: SSH into `rpi-printserver.hv.priesol.net`, `git pull`, `bundle install`, restart the service.
== Dependency updates
+87 -19
View File
@@ -8,8 +8,10 @@ require_relative 'lib/label_repository'
set :protection, :except => :frame_options
set :environment, :production
set :bind, "::"
set :port, 80
# nginx reverse-proxies :80 -> here. Loopback-only and an unprivileged port
# means no more cap_net_bind_service dance on every RVM ruby upgrade.
set :bind, "127.0.0.1"
set :port, 4567
if !File.exist?('config.json') || !File.readable?('config.json')
raise RuntimeException, 'Could not read config.json'
@@ -107,6 +109,56 @@ helpers do
receipt.print!(PRINTER_DEVICE_EPSON)
end
# Procedural 64x64 checkerboard (8px squares) -- no image library needed,
# just to prove GS v 0 raster printing works end to end.
def checkerboard_bitmap(size_px = 64, block_px = 8)
width_bytes = size_px / 8
bytes = "".b
size_px.times do |y|
row_block = y / block_px
width_bytes.times do |xb|
byte = 0
8.times do |bit|
x = xb * 8 + bit
col_block = x / block_px
byte |= (1 << (7 - bit)) if (row_block + col_block).even?
end
bytes << byte.chr
end
end
bytes
end
# Demonstrates most of what the printer can do: text sizes/styles, EAN-13,
# QR code, and a raster image -- mirrors /test-print's role for the Datamax.
def print_epson_test_page
receipt = ReceiptBuilder.new
receipt.align(:center).bold.size(:big).text("TM-T20II TEST\n").size(:normal).bold(false)
receipt.align(:left).text(('-' * 48) + "\n")
receipt.section_separator('TEXT STYLES')
receipt.text("Normal text\n")
receipt.bold.text("Bold text\n").bold(false)
receipt.underline.text("Underlined text\n").underline(false)
receipt.size(:wide).text("Double width\n").size(:normal)
receipt.size(:high).text("Double height\n").size(:normal)
receipt.size(:big).text("Double both\n").size(:normal)
receipt.section_separator('BARCODE')
receipt.align(:center).ean13('2012345678902')
receipt.section_separator('QR CODE')
receipt.align(:center).qr('https://git.priesol.net/randy/home-gitops')
receipt.section_separator('IMAGE')
receipt.align(:center).image(64, 64, checkerboard_bitmap)
receipt.align(:left).text(('-' * 48) + "\n")
receipt.text("Printed: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}\n")
receipt.feed(3).cut
receipt.print!(PRINTER_DEVICE_EPSON)
end
ESCPOS_MNEMONICS = {
'ESC' => "\x1B", 'GS' => "\x1D", 'FS' => "\x1C", 'DLE' => "\x10",
'LF' => "\x0A", 'CR' => "\x0D", 'HT' => "\x09", 'NUL' => "\x00"
@@ -117,17 +169,23 @@ helpers do
# Hello # literal text
# LF
# GS k 0x03 12345678 0x00
# Each `#...` trailing comment is stripped, then each line's
# whitespace-separated tokens become either a known control mnemonic, a
# raw `0xNN` byte, or literal text (CP852-encoded) -- consecutive literal
# tokens are rejoined with a single space so plain prose still reads
# correctly, while whitespace around control/byte tokens is dropped since
# those are just for human readability, not printer output.
# A trailing comment is only a comment when "#" is its own
# whitespace-separated token -- not just stripped from the first "#"
# anywhere in the line, which would mangle literal text like "#5 Fix
# thing" (an issue reference, not a comment). Each remaining token
# becomes a known control mnemonic, a raw `0xNN` byte, or literal text
# (CP852-encoded) -- consecutive literal tokens are rejoined with a
# single space so plain prose still reads correctly, while whitespace
# around control/byte tokens is dropped since those are just for human
# readability, not printer output.
def parse_escpos_notation(text)
bytes = "".b
text.each_line do |line|
prev_was_text = false
line.sub(/#.*/, '').split.each do |token|
tokens = line.split
comment_at = tokens.index('#')
tokens = tokens[0...comment_at] if comment_at
tokens.each do |token|
if ESCPOS_MNEMONICS.key?(token)
bytes << ESCPOS_MNEMONICS[token]
prev_was_text = false
@@ -307,6 +365,16 @@ get '/epson' do
erb :epson
end
post '/epson/test-print' do
begin
print_epson_test_page
flash 'Test page sent to Epson printer', 'success'
rescue => e
flash "Epson test print failed (#{e.message})", 'danger'
end
redirect '/epson'
end
post '/epson/print' do
begin
print_receipt(title: params[:title].to_s, body: params[:body].to_s, qr: params[:qr].to_s)
@@ -356,27 +424,27 @@ post '/epson/raw' do
redirect '/epson'
end
get '/labels' do
get '/repository' do
@labels = LABEL_REPO.all
erb :labels
erb :repository
end
get '/labels/:id/edit' do
get '/repository/:id/edit' do
@label = LABEL_REPO.find(params[:id])
halt 404, 'unknown label' unless @label
erb :label_edit
end
post '/labels/:id' do
post '/repository/:id' do
label = LABEL_REPO.update(params[:id], name: params[:name].to_s, content: params[:content].to_s)
halt 404, 'unknown label' unless label
flash "Saved '#{label['name']}'", 'success'
redirect '/labels'
redirect '/repository'
end
get '/labels/:id/preview.png' do
get '/repository/:id/preview.png' do
label = LABEL_REPO.find(params[:id])
halt 404 unless label && label['type'] == 'zpl'
@@ -387,7 +455,7 @@ get '/labels/:id/preview.png' do
png
end
post '/labels/:id/print' do
post '/repository/:id/print' do
label = LABEL_REPO.find(params[:id])
halt 404, 'unknown label' unless label
@@ -402,13 +470,13 @@ post '/labels/:id/print' do
rescue => e
flash "Print failed (#{e.message})", 'danger'
end
redirect '/labels'
redirect '/repository'
end
post '/labels/:id/delete' do
post '/repository/:id/delete' do
LABEL_REPO.delete(params[:id])
flash 'Label deleted', 'success'
redirect '/labels'
redirect '/repository'
end
get '/brother' do
+36 -2
View File
@@ -65,6 +65,28 @@ class ReceiptBuilder
self
end
# White-on-black reverse video (GS B n) -- available if a receipt ever
# wants a real highlight, but plain "=" rules read better for routine
# section breaks than a fully blacked-out line.
def invert(enable = true)
@buffer << (enable ? "\x1D\x42\x01" : "\x1D\x42\x00")
self
end
# A single "=" rule with an optional label centered in it, e.g.
# section_separator("SECTION") -> "========= SECTION =========".
def section_separator(label = '', width: 48)
line = if label.empty?
'=' * width
else
mid = " #{label} "
pad = [width - mid.length, 0].max
left = pad / 2
('=' * left) + mid + ('=' * (pad - left))
end
feed(1).text("#{line}\n").feed(1)
end
def size(mode)
@buffer << case mode
when :big then TXT_BIG
@@ -87,12 +109,24 @@ class ReceiptBuilder
self
end
# Native ESC/POS EAN-13
def ean13(code)
# Native ESC/POS EAN-13. height is in dots (GS h) -- the printer's default
# (~162 dots) prints noticeably tall; 50 is a shorter, still-scannable size.
def ean13(code, height: 50)
@buffer << "\x1D\x68" << height.chr # GS h n -- set barcode height
@buffer << "\x1D\x6B\x02" << code.b << "\x00"
self
end
# Raster bit image (GS v 0) -- bytes must already be packed 1bpp,
# row-major, (width_px/8 rounded up) bytes per row, MSB-first, 1=black.
def image(width_px, height_px, bytes)
width_bytes = (width_px + 7) / 8
@buffer << "\x1D\x76\x30\x00"
@buffer << [width_bytes % 256, width_bytes / 256, height_px % 256, height_px / 256].pack('C4')
@buffer << bytes
self
end
def feed(lines = 3)
@buffer << ("\n" * lines)
self
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="sk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="5">
<title>Print server</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet">
<style>
body { background: #212529; color: #adb5bd; }
.mdi-printer-off { font-size: 4rem; color: #6c757d; }
</style>
</head>
<body class="d-flex align-items-center justify-content-center vh-100">
<div class="text-center">
<i class="mdi mdi-printer-off"></i>
<h1 class="mt-3">Tlačiareň spí</h1>
<p class="text-muted">Print server práve nabieha alebo sa reštartuje.<br>Stránka sa sama skúsi znova o chvíľu...</p>
<div class="spinner-border text-secondary mt-2" role="status"></div>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
# Installed at /etc/nginx/sites-available/labelprint on rpi-printserver
# (symlinked into sites-enabled), not auto-deployed -- kept here just so a
# reflash/rebuild isn't a guessing game. nginx owns :80 so the app itself
# never needs cap_net_bind_service (which a plain RVM ruby patch upgrade
# silently drops, since it's tied to the old binary's path).
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name rpi-printserver.hv.priesol.net;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:4567;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 502 503 504 /502.html;
location = /502.html {
root /etc/nginx/error_pages;
internal;
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">Print server</a>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid d-flex align-items-center">
<a class="navbar-brand" href="/"><i class="mdi mdi-printer"></i> Print server</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@@ -10,7 +10,7 @@
<a class="nav-link <%= 'active' if request.path == '/' %>" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link <%= 'active' if request.path.start_with?('/brother') %>" href="/brother">Brother</a>
<a class="nav-link border-start <%= 'active' if request.path.start_with?('/brother') %>" href="/brother">Brother</a>
</li>
<li class="nav-item">
<a class="nav-link <%= 'active' if request.path == '/upload' %>" href="/upload">Shipping labels</a>
@@ -19,7 +19,7 @@
<a class="nav-link <%= 'active' if request.path == '/epson' %>" href="/epson">Receipts</a>
</li>
<li class="nav-item">
<a class="nav-link <%= 'active' if request.path.start_with?('/labels') %>" href="/labels">Labels</a>
<a class="nav-link border-start <%= 'active' if request.path.start_with?('/repository') %>" href="/repository">Labels</a>
</li>
</ul>
<% if request.path.start_with?('/brother') %>
+5 -1
View File
@@ -15,6 +15,10 @@
<button class="btn btn-secondary" type="submit">Print</button>
</form>
<form action="/epson/test-print" method="POST" class="mt-3">
<button class="btn btn-outline-secondary" type="submit">Print test page (sizes, barcode, QR, image)</button>
</form>
<hr class="mt-4">
<h5>Free text (ESC/POS mnemonic notation)</h5>
@@ -47,4 +51,4 @@ GS V 0x00 # Full cut</code></pre>
</div>
</form>
<p class="mt-4"><a href="/labels">See saved receipts &rarr;</a></p>
<p class="mt-4"><a href="/repository">See saved receipts &rarr;</a></p>
+2 -2
View File
@@ -6,7 +6,7 @@
<span class="badge bg-info text-dark">ESC/POS &middot; Epson</span>
<% end %>
<form action="/labels/<%= @label['id'] %>" method="POST" class="mt-3">
<form action="/repository/<%= @label['id'] %>" method="POST" class="mt-3">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input name="name" class="form-control" type="text" id="name" value="<%= @label['name'] %>" />
@@ -16,5 +16,5 @@
<textarea name="content" class="form-control" id="content" rows="12" style="font-family: monospace;"><%= @label['content'] %></textarea>
</div>
<button class="btn btn-secondary" type="submit">Save</button>
<a href="/labels" class="btn btn-outline-secondary">Cancel</a>
<a href="/repository" class="btn btn-outline-secondary">Cancel</a>
</form>
+6 -5
View File
@@ -5,12 +5,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAAD9/f0AAAAAAOzs7ADl5eUA/Pz8ACsZBgD+/v4AAwMDAPv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEWZmZmZmIxERZmZmZmZmERFmZmZmZmYREWZmZoZmZhERZmYUVmZmERFmYREWZmYREWYRERFmZhERZhYREXZmERFmZmYRZmYREWZmZhFmZhERZmZmERFmERFmZmYHEWYREWZmZmZmZhERZmZmZmZmERFmZmZmZmYREWZmZmZmZhHAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAAwAMAAMADAADAAwAA" rel="icon" type="image/x-icon" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div class="container">
<%= erb :_navbar, layout: false %>
<%= erb :_navbar, layout: false %>
<main class="container-fluid px-4">
<div class="row">
<% if @alert_success then %>
<div class="alert alert-success"><%=@alert_success%></div>
@@ -23,8 +24,8 @@
<% end %>
<%= yield %>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
<script>
Dropzone.options.UploadForm = {
+4 -4
View File
@@ -16,11 +16,11 @@
<strong class="ms-2"><%= label['name'] %></strong>
</div>
<div class="d-flex gap-2">
<form action="/labels/<%= label['id'] %>/print" method="POST">
<form action="/repository/<%= label['id'] %>/print" method="POST">
<button class="btn btn-sm btn-secondary" type="submit">Print</button>
</form>
<a href="/labels/<%= label['id'] %>/edit" class="btn btn-sm btn-outline-secondary">Edit</a>
<form action="/labels/<%= label['id'] %>/delete" method="POST">
<a href="/repository/<%= label['id'] %>/edit" class="btn btn-sm btn-outline-secondary">Edit</a>
<form action="/repository/<%= label['id'] %>/delete" method="POST">
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
</form>
</div>
@@ -28,7 +28,7 @@
<div class="mt-2">
<% if label['type'] == 'zpl' %>
<img src="/labels/<%= label['id'] %>/preview.png" alt="preview" style="max-height: 150px;" onerror="this.remove()" />
<img src="/repository/<%= label['id'] %>/preview.png" alt="preview" style="max-height: 150px;" onerror="this.remove()" />
<% else %>
<pre class="bg-light p-2 mb-0" style="max-height: 150px; overflow: auto;"><%= label['content'] %></pre>
<% end %>
+1 -1
View File
@@ -33,4 +33,4 @@
</div>
</form>
<p class="mt-4"><a href="/labels">See saved labels &rarr;</a></p>
<p class="mt-4"><a href="/repository">See saved labels &rarr;</a></p>