Compare commits

...

16 commits

Author SHA1 Message Date
e9f2dec500 Merge pull request 'Add Pablo: the friendly label printer helper' (#5) from add-pablo into master
Some checks failed
MegaLinter / MegaLinter (push) Has been cancelled
Reviewed-on: #5
2026-04-16 14:02:46 +00:00
84963b42e2 Open fegen/docs/index.html as "rb"
Some checks failed
MegaLinter / MegaLinter (pull_request) Has been cancelled
Update website / update (pull_request) Has been cancelled
2026-04-16 13:58:56 +00:00
1ea09e0408 Add more printer description in readme
Some checks failed
MegaLinter / MegaLinter (pull_request) Has been cancelled
Update website / update (pull_request) Has been cancelled
2026-04-16 13:53:01 +00:00
Piotr Gaczkowski
cebafd3cec feat: Add basic frontend
Some checks failed
MegaLinter / MegaLinter (pull_request) Has been cancelled
Update website / update (pull_request) Has been cancelled
2026-04-16 14:59:07 +02:00
Piotr Gaczkowski
2a2b5973df feat: Add Web iface with QR codes 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
3897d37f2a support more tape widths 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
6b50d02d60 expand docs 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
aad7c67d48 added support for printing simple labels 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
92bf65dc62 listed supported commands 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
c6787d8c2e starter docs 2026-04-16 13:43:50 +02:00
Hubert Bryłkowski
c721e4c909 simplified dockerfile 2026-04-16 13:42:21 +02:00
Hubert Bryłkowski
19516d50fc build on every push 2026-04-16 13:42:21 +02:00
Hubert Bryłkowski
cda1ba4f07 add action for building docker 2026-04-16 13:42:19 +02:00
Hubert Bryłkowski
27798e6e54 simple bot for getting media info 2026-04-16 13:41:44 +02:00
Hubert Bryłkowski
8c420af0af add function for getting info about installed tape 2026-04-16 13:41:44 +02:00
Hubert Bryłkowski
3865dcc402 init commit with repo structure and basic files 2026-04-16 13:41:44 +02:00
49 changed files with 4907 additions and 1470 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.venv
.git
__pycache__
*.pyc
.ruff_cache
.mypy_cache

47
.github/workflows/build_docker.yml vendored Normal file
View file

@ -0,0 +1,47 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Publish Docker image
on:
push:
branches:
- main
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
# Build stage
FROM python:3.11 AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
COPY . .
RUN uv sync
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app/ /app/
ENV PATH="/app/.venv/bin:$PATH"
CMD ["fastapi", "dev", "--host", "0.0.0.0", "--port", "31337", "labeler/adapter/fastapi_srv.py" ]

View file

@ -2,6 +2,24 @@
Skrypt, który generuje podsumowanie [Bazy Wiedzy zasobów Hackerspace Trójmiasto](https://kb.hs3.pl/docs) w formie statycznej strony internetowej. Skrypt, który generuje podsumowanie [Bazy Wiedzy zasobów Hackerspace Trójmiasto](https://kb.hs3.pl/docs) w formie statycznej strony internetowej.
## Uruchomienie połączenia z drukarką etykiet
Potrzebne, by działał przycisk w kolumnie `print`. Po kliknięciu, nastąpi próba połączenia się z drukarką Brother PT-E550W, pod adresem IP zdefiniowanym w `PRINTER_IT`, w celu wydruku naklejki z kodem QR.
```bash
uv venv --python 3.11
source .venv.bin/activate
uv sync
fastapi dev --port 31337 labeler/adapter/fastapi_srv.py
export PRINTER_IT=192.168.0.147
```
W razie problemów, spróbuj alternatywnych komend:
```bash
source .venv/Scripts/activate
uv run -- fastapi dev --port 31337 labeler/adapter/fastapi_srv.py
```
## Sposób działania ## Sposób działania
1. Baza Wiedzy znajduje się na Discourse Hackerspace Trójmiasto i jest dostępna publicznie. Projekt wykorzystuje Discourse REST API do pobrania listy zasobów. 1. Baza Wiedzy znajduje się na Discourse Hackerspace Trójmiasto i jest dostępna publicznie. Projekt wykorzystuje Discourse REST API do pobrania listy zasobów.
@ -43,3 +61,4 @@ ID, nazwa, miejsce, ilość, opiekunowie, tagi
## Dokumentacja ## Dokumentacja
- [Discourse REST API](https://docs.discourse.org/) - [Discourse REST API](https://docs.discourse.org/)
- [hbrylkowski/labeling_bot](https://github.com/hbrylkowski/labeling_bot)

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: "3.8"
services:
bot:
build:
context: .
dockerfile: Dockerfile
environment:
- PRINTER_IP=192.168.1.93
- TELEGRAM_TOKEN=6386069775:AAFOQL7lIsDe_5njXptfOwmHWEVo_yyVAi4
command:
- python
- labeler/adapter/telegram_bot.py

0
fegen/__init__.py Normal file
View file

View file

@ -16,6 +16,7 @@ PLACES = [
"audiolab", "audiolab",
"server-room" "server-room"
] ]
class DiscourseDatabase(): class DiscourseDatabase():
def __init__(self): def __init__(self):
data = self.get_category_data() data = self.get_category_data()

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
import os, re, shutil import os, re, shutil
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
import pandas as pd import pandas as pd
from discourse import DiscourseDatabase
def add_download_button(row): def add_download_button(row):
@ -9,23 +8,22 @@ def add_download_button(row):
download_button = ( download_button = (
f'<button id="btn_{item_id}"><i class="fa fa-download"></i> {item_id}</button>' f'<button id="btn_{item_id}"><i class="fa fa-download"></i> {item_id}</button>'
) )
return row + [download_button] print_button = f'<a href="http://localhost:31337/print/{item_id}"><button id="prn_{item_id}"><i class="fa fa-print"></i> {item_id}</button></a>'
return row + [download_button, print_button]
def generate_dashboard(): def generate_dashboard():
"""Generate dashboard from zasoby.csv file""" """Generate dashboard from zasoby.csv file"""
print("Generating HTML dashboard") print("Generating HTML dashboard")
website_folder = "docs" website_folder = "fegen/docs"
data = pd.read_csv("zasoby.csv") data = pd.read_csv("zasoby.csv")
env = Environment(loader=FileSystemLoader("template")) env = Environment(loader=FileSystemLoader("fegen/template"))
print("Removing old website files") print("Removing old website files")
shutil.rmtree(f"./{website_folder}")
os.mkdir(f"./{website_folder}")
print("Creating a new website") print("Creating a new website")
shutil.copytree("template/static", f"{website_folder}/static") shutil.copytree("fegen/template/static", f"{website_folder}/static", dirs_exist_ok=True)
template = env.get_template("_main_layout.html") template = env.get_template("_main_layout.html")
with open(f"{website_folder}/index.html", "w+", encoding="utf-8") as file: with open(f"{website_folder}/index.html", "w+", encoding="utf-8") as file:
header_row = data.columns.values.tolist() + ["label"] header_row = data.columns.values.tolist() + ["label", "print"]
rows = map( rows = map(
add_download_button, add_download_button,
data.values.tolist(), data.values.tolist(),
@ -39,7 +37,8 @@ def generate_dashboard():
if __name__ == "__main__": if __name__ == "__main__":
from discourse import DiscourseDatabase
DiscourseDatabase() DiscourseDatabase()
generate_dashboard() generate_dashboard()
print("Done!") print("Done!")

View file

@ -4,6 +4,7 @@
<div class="sidenav">{% block sidenav %}{% endblock sidenav %}</div> <div class="sidenav">{% block sidenav %}{% endblock sidenav %}</div>
<div class="main"> <div class="main">
<h1>Baza Zasobów Hackerspace Trójmiasto</h1> <h1>Baza Zasobów Hackerspace Trójmiasto</h1>
<a href="/refresh"><button id="refresh"><i class="fa fa-arrows-rotate"></i> Refresh</button></a>
<table id="dashboardTable"> <table id="dashboardTable">
<thead> <thead>
<tr> <tr>

Binary file not shown.

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

75
labeler/adapter/cli.py Normal file
View file

@ -0,0 +1,75 @@
import os
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
class LabelingBot:
def __init__(self, app: Application):
self.app = app
def media_info(self):
media = self.app.get_installed_media()
print(f"Installed medium: {media}")
def simple_label(self, label_text, label_length=0):
try:
label = self.app.print_label(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
def get_qrcode(self, label_text, label_length=0):
label = self.app.render_qrcode_preview(text=label_text, length=label_length)
return label
def print_qrcode(self, label_text, label_length=0):
try:
label = self.app.print_qrcode(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
# async def label_length(self):
# await update.message.reply_text(
# "Hello! Please tell me the length of the label, enter 0 for auto:"
# )
# return LABEL_LENGTH
#
# async def label_text(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["length"] = int(user_input)
# await update.message.reply_text("Now, please tell me the text of the label:")
# return LABEL_TEXT
#
# async def simple_label(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["label"] = user_input
# try:
# label = self.app.print_label(
# text=context.user_data["label"], length=context.user_data["length"]
# )
# except Exception as e:
# await update.message.reply_text(f"There was an exception: {e}")
# return ConversationHandler.END
#
# await update.message.reply_photo(
# label.bytes, f'Your label is: {context.user_data["label"]}'
# )
# return ConversationHandler.END
#
# async def cancel(self, update: Update, context: CallbackContext) -> int:
# await update.message.reply_text("Cancelled.")
# return ConversationHandler.END
if __name__ == "__main__":
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
bot.media_info()
label = bot.get_qrcode("512", 25)
with open("label.png", "wb") as preview:
preview.write(label.bytes)
label = bot.print_qrcode("512", 25)

View file

@ -0,0 +1,98 @@
import os
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fegen import discourse, main
app = FastAPI()
app.mount("/static", StaticFiles(directory="fegen/docs/static"), name="static")
class LabelingBot:
def __init__(self, app: Application):
self.app = app
def media_info(self):
media = self.app.get_installed_media()
print(f"Installed medium: {media}")
def simple_label(self, label_text, label_length=0):
try:
label = self.app.print_label(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
def get_qrcode(self, label_text, label_length=0):
label = self.app.render_qrcode_preview(text=label_text, length=label_length)
return label
def print_qrcode(self, label_text, label_length=0):
try:
label = self.app.print_qrcode(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
# async def label_length(self):
# await update.message.reply_text(
# "Hello! Please tell me the length of the label, enter 0 for auto:"
# )
# return LABEL_LENGTH
#
# async def label_text(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["length"] = int(user_input)
# await update.message.reply_text("Now, please tell me the text of the label:")
# return LABEL_TEXT
#
# async def simple_label(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["label"] = user_input
# try:
# label = self.app.print_label(
# text=context.user_data["label"], length=context.user_data["length"]
# )
# except Exception as e:
# await update.message.reply_text(f"There was an exception: {e}")
# return ConversationHandler.END
#
# await update.message.reply_photo(
# label.bytes, f'Your label is: {context.user_data["label"]}'
# )
# return ConversationHandler.END
#
# async def cancel(self, update: Update, context: CallbackContext) -> int:
# await update.message.reply_text("Cancelled.")
# return ConversationHandler.END
@app.get("/", response_class=HTMLResponse)
async def root():
with open("fegen/docs/index.html", "rb") as f:
index = f.read()
return index
@app.get("/print/{item_id}")
def print_item(item_id: int, q: str | None = None):
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
bot.media_info()
label = bot.get_qrcode(item_id, 25)
with open("label.png", "wb") as preview:
preview.write(label.bytes)
bot.print_qrcode(item_id, 25)
return RedirectResponse(url="/", status_code=302)
@app.get("/refresh")
def refresh():
discourse.DiscourseDatabase()
main.generate_dashboard()
return RedirectResponse(url="/", status_code=302)

View file

@ -0,0 +1,82 @@
import os
from telegram import Update
from telegram.ext import (
CommandHandler,
ApplicationBuilder,
ConversationHandler,
CallbackContext,
filters,
MessageHandler,
)
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
class LabelingBot:
def __init__(self, app: Application):
self.app = app
async def media_info(self, update, context):
media = self.app.get_installed_media()
await update.message.reply_text(f"Installed media: {media.description}")
async def label_length(self, update, context):
await update.message.reply_text(
"Hello! Please tell me the length of the label, enter 0 for auto:"
)
return LABEL_LENGTH
async def label_text(self, update: Update, context: CallbackContext) -> int:
user_input = update.message.text
context.user_data["length"] = int(user_input)
await update.message.reply_text("Now, please tell me the text of the label:")
return LABEL_TEXT
async def simple_label(self, update: Update, context: CallbackContext) -> int:
user_input = update.message.text
context.user_data["label"] = user_input
try:
label = self.app.print_label(
text=context.user_data["label"], length=context.user_data["length"]
)
except Exception as e:
await update.message.reply_text(f"There was an exception: {e}")
return ConversationHandler.END
await update.message.reply_photo(
label.bytes, f'Your label is: {context.user_data["label"]}'
)
return ConversationHandler.END
async def cancel(self, update: Update, context: CallbackContext) -> int:
await update.message.reply_text("Cancelled.")
return ConversationHandler.END
if __name__ == "__main__":
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
conv_handler = ConversationHandler(
entry_points=[CommandHandler("simple_label", bot.label_length)],
states={
LABEL_LENGTH: [
MessageHandler(filters.Text() & ~filters.Command(), bot.label_text)
],
LABEL_TEXT: [
MessageHandler(filters.Text() & ~filters.Command(), bot.simple_label)
],
},
fallbacks=[CommandHandler("cancel", bot.cancel)],
)
app = ApplicationBuilder().token(os.environ["TELEGRAM_TOKEN"]).build()
app.add_handler(CommandHandler("media_info", bot.media_info))
app.add_handler(conv_handler)
app.run_polling()

90
labeler/app/labeler.py Normal file
View file

@ -0,0 +1,90 @@
from labeler.domain.objects import (
Label,
LabelRequest,
LabelDefinition,
MediaDefinition,
Dimension,
Image,
)
from labeler.interfaces import Renderer, Printer
class Application:
def __init__(self, renderer: Renderer, printer: Printer):
self.renderer = renderer
self.printer = printer
def render_preview(self, text: str, length: int = None) -> Label:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
self.renderer.render_label(label_definition)
def render_qrcode_preview(self, text: str, length: int = None) -> Label:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
return self.renderer.render_qrcode(label_definition)
def print_label(self, text: str, length: int = None) -> Image:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
label = self.renderer.render_label(label_definition)
self.printer.print_label(label)
return label
def print_qrcode(self, text: str, length: int = None) -> Image:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
label = self.renderer.render_qrcode(label_definition)
self.printer.print_label(label)
return label
def get_installed_media(self) -> MediaDefinition:
return self.printer.get_installed_media()

125
labeler/domain/objects.py Normal file
View file

@ -0,0 +1,125 @@
import io
from math import inf
from pydantic import BaseModel, Field
class Image(BaseModel):
bytes: bytes
width: int
height: int
@classmethod
def from_pil(cls, pil_image):
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
buffer.seek(0)
return cls(
bytes=buffer.read(),
width=pil_image.width,
height=pil_image.height,
)
class Dimension(BaseModel):
mm: float
__EPSILON = 0.0001
@classmethod
def from_inch(cls, inch: float) -> "Dimension":
return cls(mm=inch * 25.4)
@classmethod
def from_points(cls, points: float, dpi: int) -> "Dimension":
return cls.from_inch(points / dpi)
@property
def inch(self) -> float:
return self.mm / 25.4
def in_pixels(self, dpi: int) -> int:
return int(self.inch * dpi)
def __ensure_same_type(self, other):
if type(other) != Dimension:
raise TypeError(f"Cannot use {other} to {self}")
def __add__(self, other):
self.__ensure_same_type(other)
return Dimension(mm=self.mm + other.mm)
def __sub__(self, other):
self.__ensure_same_type(other)
return Dimension(mm=self.mm - other.mm)
def __mul__(self, other):
if type(other) not in (int, float):
raise TypeError(f"Cannot multiply {self} by {other}")
return Dimension(mm=self.mm * other)
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
if type(other) not in (int, float):
raise TypeError(f"Cannot divide {self} by {other}")
return Dimension(mm=self.mm / other)
def __eq__(self, other):
self.__ensure_same_type(other)
if self.mm == inf and other.mm == inf:
return True
return abs(self.mm - other.mm) < self.__EPSILON
def __lt__(self, other):
self.__ensure_same_type(other)
return self.mm < other.mm
def __gt__(self, other):
self.__ensure_same_type(other)
return self.mm > other.mm
class LabelRequest(BaseModel):
text: str
length: Dimension | None
class LabelDefinition(BaseModel):
text: str
length: Dimension | None = None
width: Dimension
dpi: int
@property
def pixel_width(self):
return self.width.in_pixels(self.dpi)
@property
def pixel_length(self):
return self.length.in_pixels(self.dpi)
class MediaDefinition(BaseModel):
width: Dimension
length: Dimension
minimal_margin_vertical: Dimension
minimal_margin_horizontal: Dimension
minimum_length: Dimension = Field(default_factory=lambda: Dimension(mm=5))
dpi: int
description: str
@property
def printable_width(self) -> Dimension:
return self.width - 2 * self.minimal_margin_vertical
@property
def printable_length(self) -> Dimension:
return self.length - 2 * self.minimal_margin_horizontal
class Label(BaseModel):
dpi: str
image: Image

View file

@ -0,0 +1,81 @@
"""
Values from technical reference manual, can be found in /labeler_docs/brother/technical_reference_manual.pdf
"""
WIDTH_BYTE = 10
TYPE_BYTE = 11
COLOR_BYTE = 24
TEXT_COLOR_BYTE = 25
def media_width(code):
if code == 0:
raise ValueError("NO TAPE")
elif code == 4:
return 3.5
else:
return code
def media_type(code):
media = {
0: "NO TAPE",
1: "Laminated tape",
0x11: "Heat-Shrink Tube",
0x03: "Non-laminated tape",
0xFF: "Incompatible tape",
}
return media.get(code)
def tape_color(code):
colors = {
0x01: "White",
0x02: "Other",
0x03: "Clear",
0x04: "Red",
0x05: "Blue",
0x06: "Yellow",
0x07: "Green",
0x08: "Black",
0x09: "Clear(White text)",
0x20: "Matte White",
0x21: "Matte Clear",
0x22: "Matte Silver",
0x23: "Satin Gold",
0x24: "Satin Silver",
0x30: "Blue(D)",
0x31: "Red(D)",
0x40: "Fluorescent Orange",
0x41: "Fluorescent Yellow",
0x50: "Berry Pink(S)",
0x51: "Light Gray(S)",
0x52: "Lime Green(S)",
0x60: "Yellow(F)",
0x61: "Pink(F)",
0x62: "Blue(F)",
0x70: "White(Heat-shrink Tube)",
0x90: "White(Flex. ID)",
0x91: "Yellow(Flex. ID)",
0xF0: "Cleaning",
0xF1: "Stencil",
0xFF: "Incompatible",
}
return colors.get(code)
def text_color(code):
colors = {
0x01: "White",
0x04: "Red",
0x05: "Blue",
0x08: "Black",
0x0A: "Gold",
0x62: "Blue(F)",
0xF0: "Cleaning",
0xF1: "Stencil",
0x02: "Other",
0xFF: "Incompatible",
}
return colors.get(code)

View file

@ -0,0 +1,127 @@
import io
import logging
from math import inf
from brother_ql import BrotherQLRaster, create_label
from brother_ql.backends import guess_backend, backend_factory
from brother_ql.conversion import convert
from pysnmp.entity.engine import SnmpEngine
from pysnmp.hlapi import getCmd, CommunityData, UdpTransportTarget, ContextData
from pysnmp.smi.rfc1902 import ObjectType, ObjectIdentity
from labeler.domain.objects import MediaDefinition, Dimension, Image
from labeler.infra.e550w_printer.media_definitions import (
media_width,
tape_color,
text_color,
media_type,
WIDTH_BYTE,
COLOR_BYTE,
TEXT_COLOR_BYTE,
TYPE_BYTE,
)
from labeler.interfaces import Printer
from PIL import Image as PILImage
PRINTABLE_WIDTH = {
6: Dimension.from_points(64, 360),
9: Dimension.from_points(100, 360),
12: Dimension.from_points(140, 360),
18: Dimension.from_points(224, 360),
24: Dimension.from_points(256, 360),
}
class E550W(Printer):
def __init__(self, ip_address: str):
self.ip_address = ip_address
self.snmp_port = 161
def get_installed_media(self) -> MediaDefinition:
return self.__get_printer_status()
def print_label(self, label: Image):
im = PILImage.open(io.BytesIO(label.bytes))
qlr = BrotherQLRaster("PT-E550W")
convert(
qlr,
[im],
self.__media_width_to_type(label.height),
red=False,
threshold=70,
cut=True,
rotate=90,
compress=True,
dpi_600=True,
hq=True,
)
try:
try:
selected_backend = guess_backend(f"tcp://{self.ip_address}:9100")
except ValueError:
logging.error(
"Couln't guess the backend to use from the printer string descriptor"
)
BACKEND_CLASS = backend_factory(selected_backend)["backend_class"]
be = BACKEND_CLASS(f"tcp://{self.ip_address}:9100")
be.write(qlr.data)
be.dispose()
del be
except Exception as e:
logging.exception("Exception happened: %s", e)
def __media_width_to_type(self, height: int):
metric_width = Dimension.from_points(height, 360)
for width, printable_width in PRINTABLE_WIDTH.items():
if printable_width == metric_width:
return f"pt5{width}"
raise ValueError(f"Unsupported media width: {metric_width}")
def __get_printer_status(self):
raw_snmp_data = self.__get_snmp_status().asNumbers()
width = media_width(raw_snmp_data[WIDTH_BYTE])
media_tape_color = tape_color(raw_snmp_data[COLOR_BYTE])
media_text_color = text_color(raw_snmp_data[TEXT_COLOR_BYTE])
tape_type = media_type(raw_snmp_data[TYPE_BYTE])
return MediaDefinition(
width=Dimension(mm=width),
length=Dimension(mm=inf),
minimal_margin_vertical=(Dimension(mm=width) - PRINTABLE_WIDTH[width]) / 2,
minimal_margin_horizontal=Dimension(mm=1),
dpi=360,
description=f"{tape_type} - {width}mm, {media_text_color} on {media_tape_color} background",
)
def __get_snmp_status(self):
"""
This oid was found by using wireshark, however it's also documented here:
https://support.brother.com/g/s/es/dev/en/command/faq/index.html?c=eu_ot&lang=en&navi=offall&comple=on&redirect=on
just not for the E550W model.
"""
oid = "1.3.6.1.4.1.2435.3.3.9.1.6.1.0"
error_indication, error_status, error_index, var_binds = next(
getCmd(
SnmpEngine(),
CommunityData("public", mpModel=0),
UdpTransportTarget((self.ip_address, self.snmp_port)),
ContextData(),
ObjectType(ObjectIdentity(oid)),
)
)
if error_indication:
raise Exception(error_indication)
elif error_status:
raise Exception(
"%s at %s"
% (
error_status.prettyPrint(),
error_index and var_binds[int(error_index) - 1][0] or "?",
)
)
else:
return var_binds[0][1]

169
labeler/infra/renderer.py Normal file
View file

@ -0,0 +1,169 @@
import qrcode
import textwrap
from string import ascii_letters
from PIL import ImageFont, ImageDraw, Image as PILImage
from labeler.domain.objects import Image, LabelDefinition
from labeler.interfaces import Renderer
DPI = 360.0
def points_to_pixels(point_size: float):
return int(point_size * (72 / DPI))
class PILRenderer(Renderer):
def __init__(self):
self.font_path = "/Library/Fonts/Arial Unicode.ttf"
def render_label(self, label_definition: LabelDefinition) -> Image:
if label_definition.length is None:
pil_image = self.__render_no_fixed_lenth(label_definition)
else:
pil_image = self.__render_fixed_length(label_definition)
return Image.from_pil(pil_image)
def __render_fixed_length(self, label_definition: LabelDefinition):
width = label_definition.pixel_width
length = label_definition.pixel_length
font, text = self.__get_font(
label_definition.text,
width,
length,
)
im = PILImage.new("1", (length, width), 1)
draw = ImageDraw.Draw(im)
draw.text(
(length / 2, width / 2),
text,
font=font,
fill=0,
anchor="mm",
align="center",
)
return im
def __render_no_fixed_lenth(self, label_definition: LabelDefinition):
lines_to_print = label_definition.text.count("\n") + 1
text = "\n".join([line.strip() for line in label_definition.text.split("\n")])
text_height = label_definition.pixel_width // lines_to_print
while text_height > 0:
font = ImageFont.truetype(
"/Library/Fonts/Arial Unicode.ttf",
text_height,
)
if lines_to_print > 1:
occupied_height = font.getsize_multiline(text)[1]
else:
occupied_height = font.getsize(text)[1]
if occupied_height <= label_definition.pixel_width:
break
text_height -= 1
sizes = [font.getsize(line) for line in text.split("\n")]
length = max(length for length, height in sizes)
im = PILImage.new("1", (length, label_definition.pixel_width), 1)
draw = ImageDraw.Draw(im)
draw.text(
(length / 2, label_definition.pixel_width / 2),
text,
font=font,
fill=0,
anchor="mm",
align="center",
)
return im
def render_qrcode(self, label_definition: LabelDefinition) -> Image:
width = label_definition.pixel_width
length = label_definition.pixel_length
text = label_definition.text
font = self.__get_font_for_qr()
qr = qrcode.QRCode(box_size=7)
qr.add_data(f"https://hs3.pl/db/{text}")
qr.make(fit=True)
qr_img = qr.make_image()
print(width, length)
pil_image = PILImage.new("1", (width, length), 1)
draw = ImageDraw.Draw(pil_image)
pil_image.paste(qr_img)
draw.text(
(width / 2, 232),
"HS3-DB",
font=font,
fill=0,
anchor="mm",
align="center",
)
draw.text(
(20, 232 + 34),
f"ID: {text}",
font=font,
fill=0,
align="left",
)
return Image.from_pil(pil_image.transpose(PILImage.Transpose.ROTATE_90))
def __get_font_for_qr(self):
font_size = int(360 / 9)
return ImageFont.truetype("fonts/SourceCodePro-SemiBold.ttf", font_size)
def __get_font(self, text: str, max_width: int, max_length: int):
font_size = max_width
step = max_width // 2
last_good = None
last_corrected = None
while step > 1:
fits, corrected = self.__will_font_fit(
text, self.font_path, font_size, max_width, max_length
)
if fits:
last_good = font_size
last_corrected = corrected
font_size += step
else:
font_size -= step
step //= 2
return ImageFont.truetype(self.font_path, last_good), last_corrected
def __will_font_fit(
self, text: str, font_path: str, font_size: int, max_width: int, max_length: int
):
font = ImageFont.truetype(font_path, font_size)
if "\n" in text:
text_width, text_height = font.getsize_multiline(text)
else:
text_width, text_height = font.getsize(text)
if text_height > max_width:
return False, None
if text_width <= max_length:
# Now we know that the text fits. We can stop trying
return True, text
avg_char_width = sum(font.getsize(char)[0] for char in ascii_letters) / len(
ascii_letters
)
charachters_per_line = max_length // avg_char_width
if charachters_per_line < max(len(line) for line in text.split(" ")):
return False, None
wrapped = textwrap.fill(text, charachters_per_line)
wrapped_width, wrapped_height = font.getsize_multiline(wrapped)
if wrapped_height <= max_width and wrapped_width <= max_length:
# Now we know that the text fits. We can stop trying
return True, wrapped
return False, None

23
labeler/interfaces.py Normal file
View file

@ -0,0 +1,23 @@
import abc
from labeler.domain.objects import LabelDefinition, Image, MediaDefinition
class Renderer(abc.ABC):
@abc.abstractmethod
def render_label(self, label_definition: LabelDefinition) -> Image:
pass
@abc.abstractmethod
def render_qrcode(self, label_definition: LabelDefinition) -> Image:
pass
class Printer(abc.ABC):
@abc.abstractmethod
def get_installed_media(self) -> MediaDefinition:
pass
@abc.abstractmethod
def print_label(self, label: Image):
pass

Binary file not shown.

36
lbot_readme.md Normal file
View file

@ -0,0 +1,36 @@
## labeling telegram bot
This application is build to render and print labels sent to it via telegram, as well
as provide info about printer status and other useful information.
## Supported commands
- `/media_info` - show info about currently installed media
![img.png](img.png)
- `/simple_label` - print a simple label
![img_1.png](img_1.png)
### Usage example
You need three things:
1. A telegram bot token, you can write to [@BotFather](https://t.me/BotFather) to get one
2. A compatible printer
3. docker installed on your system
```yaml
version: "3.8"
services:
bot:
build:
context: .
dockerfile: Dockerfile
environment:
- PRINTER_IP=<printer_ip>
- TELEGRAM_TOKEN=<telegram_bot_token>
command:
- python
- labeler/adapter/telegram_bot.py
```
### Supported printers
- Brother PT-E550W

37
pyproject.toml Normal file
View file

@ -0,0 +1,37 @@
[project]
name = "python-scratchpad"
version = "0.1.0"
description = ""
# authors = ["Hubert Bryłkowski <hubert@brylkowski.com>"]
readme = "readme.md"
requires-python = ">=3.11"
dependencies = [
"brother-ql @ git+https://github.com/hbrylkowski/brother_ql@4225d13d209e8e4a2c17e87a75f42809e0da8fda",
"qrcode[pil]",
# https://github.com/astral-sh/uv/issues/6384
"setuptools<81",
"jinja2>=3.1.2,<4",
"pillow>=9.5.0,<10",
"pysnmp>=4.4.12,<5",
"pyasn1==0.4.8,<0.5",
"python-telegram-bot>=20.3,<21",
"fastapi[standard]>=0.114.0",
"requests==2.32.5",
"Jinja2==3.1.6",
"pandas==2.3.3",
"python-dotenv==1.2.1",
]
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
black = "^23.3.0"
[tool.hatch]
metadata.allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["labeler"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -1,4 +0,0 @@
requests==2.32.5
Jinja2==3.1.6
pandas==2.3.3
python-dotenv==1.2.1

1
tests/conftest.py Normal file
View file

@ -0,0 +1 @@
from fixtures import * # noqa

48
tests/fixtures.py Normal file
View file

@ -0,0 +1,48 @@
import os
from typing import Callable
import pytest
from labeler.domain.objects import Dimension, MediaDefinition
@pytest.fixture
def current_dir(request) -> str:
return os.path.dirname(request.module.__file__)
@pytest.fixture
def get_test_image(current_dir) -> Callable[[str], bytes]:
def f(name):
return open(os.path.join(current_dir, "test_images", name), "rb").read()
return f
@pytest.fixture
def save_test_image(current_dir) -> Callable[[str, bytes], None]:
def f(name: str, data: bytes):
open(os.path.join(current_dir, "test_images", name), "wb").write(data)
return f
@pytest.fixture
def create_test_media() -> Callable[[int, int, int, int, int], MediaDefinition]:
def f(
width: int,
height: int,
dpi: int = 600,
margin_horizontal: int = 0,
margin_vertical: int = 0,
):
return MediaDefinition(
width=Dimension(mm=width),
length=Dimension(mm=height),
minimal_margin_horizontal=Dimension(mm=margin_horizontal),
minimal_margin_vertical=Dimension(mm=margin_vertical),
dpi=dpi,
description=f"test media {width}mm x{height}mm @ {dpi}dpi",
)
return f

View file

@ -0,0 +1,30 @@
import math
from labeler.domain.objects import Dimension, MediaDefinition
def test_dimension():
dimension = Dimension(mm=25.4)
assert dimension.mm == 25.4
assert dimension.inch == 1.0
assert dimension.in_pixels(dpi=300) == 300
assert dimension + Dimension(mm=10) == Dimension(mm=35.4)
assert dimension * 2 == Dimension(mm=50.8)
assert dimension / 2 == Dimension(mm=12.7)
def test_infinite_media():
media = MediaDefinition(
width=Dimension(mm=12),
length=Dimension(mm=math.inf),
minimal_margin_vertical=Dimension(mm=1),
minimal_margin_horizontal=Dimension(mm=2),
dpi=300,
description="test media",
)
assert media.printable_length == Dimension(mm=math.inf)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,50 @@
from labeler.domain.objects import LabelDefinition, Dimension
from labeler.infra.renderer import PILRenderer
def test_simple_label(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("simple_label_test.png")
definition = LabelDefinition(
text="dolphin", length=Dimension(mm=40), width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_multiline_label(get_test_image):
label_text = "dolphin\nis\nawesome"
expected_label = get_test_image("multiline_label_test.png")
renderer = PILRenderer()
definition = LabelDefinition(
text=label_text, length=Dimension(mm=40), width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_simple_label_no_fixed_width(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("no_fixed_width.png")
definition = LabelDefinition(text="dolphin", width=Dimension(mm=10), dpi=600)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_multiline_label_no_fixed_width(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("multiline_label_no_fixed_width.png")
definition = LabelDefinition(
text="dolphin\nis\nawesome", width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label

1206
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,22 @@
id,title,place,tags id,title,place,tags
45,"<a href=""https://kb.hs3.pl/t/45"">Jak stworzyć nowy wpis do bazy zasobów Hackerspace Trójmiasto?</a>",unknown,[] 45,"<a href=""https://kb.hs3.pl/t/45"">Jak stworzyć nowy wpis do bazy zasobów Hackerspace Trójmiasto?</a>",unknown,[]
20,"<a href=""https://kb.hs3.pl/t/20"">O kategorii: Baza Wiedzy Hackerspace'u</a>",unknown,[] 20,"<a href=""https://kb.hs3.pl/t/20"">O kategorii: Baza Wiedzy Hackerspace'u</a>",unknown,[]
747,"<a href=""https://kb.hs3.pl/t/747"">Gra handheld ""Crazy Brick""</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'video-game']"
745,"<a href=""https://kb.hs3.pl/t/745"">Statyw na aparat</a>",unknown,[]
735,"<a href=""https://kb.hs3.pl/t/735"">STD17NF03L</a>",unknown,[]
734,"<a href=""https://kb.hs3.pl/t/734"">Szuflada z procesorami STM32</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab']
732,"<a href=""https://kb.hs3.pl/t/732"">Eksplodujące pudełko HS3</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'projects']"
731,"<a href=""https://kb.hs3.pl/t/731"">STM32F723</a>",unknown,[]
730,"<a href=""https://kb.hs3.pl/t/730"">NUCLEO G431RB</a>",unknown,[]
729,"<a href=""https://kb.hs3.pl/t/729"">Nucleo Expansion Board Led Driver</a>",unknown,[]
728,"<a href=""https://kb.hs3.pl/t/728"">Nucleo Expansion Board MultiSensor</a>",unknown,[]
285,"<a href=""https://kb.hs3.pl/t/285"">Konsola do gier Sony PlayStation 2 Slim + kontroler Namco GunCon</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
480,"<a href=""https://kb.hs3.pl/t/480"">Gitara elektryczna Blond TE-1 MN BB</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
481,"<a href=""https://kb.hs3.pl/t/481"">Gitara elektryczna Blond STR-1H MN SFG</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
479,"<a href=""https://kb.hs3.pl/t/479"">Guitalele Ever Play GT-WBK</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
456,"<a href=""https://kb.hs3.pl/t/456"">Wzmacniacz gitarowy Roland Micro Cube</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
530,"<a href=""https://kb.hs3.pl/t/530"">Discman SONY</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
531,"<a href=""https://kb.hs3.pl/t/531"">Streamer LTO-4 HP M8609A</a>","<a href=""https://kb.hs3.pl/tag/server-room"">server-room</a>",['server-room']
376,"<a href=""https://kb.hs3.pl/t/376"">Drukarka 3D HEVO (Hypercube Evolution</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', '3d-print']" 376,"<a href=""https://kb.hs3.pl/t/376"">Drukarka 3D HEVO (Hypercube Evolution</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', '3d-print']"
699,"<a href=""https://kb.hs3.pl/t/699"">Gra Blood Bowl z przyległościami</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'audiolab', 'boardgame', 'sticker-needed']" 699,"<a href=""https://kb.hs3.pl/t/699"">Gra Blood Bowl z przyległościami</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'audiolab', 'boardgame', 'sticker-needed']"
720,"<a href=""https://kb.hs3.pl/t/720"">Płytki ewaluacyjne STEVAL-VP318L1F +?</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab'] 720,"<a href=""https://kb.hs3.pl/t/720"">Płytki ewaluacyjne STEVAL-VP318L1F +?</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab']
@ -31,7 +47,6 @@ id,title,place,tags
377,"<a href=""https://kb.hs3.pl/t/377"">Drukarka 3D “Elegoo Neptune 4 Pro”</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', '3d-print']" 377,"<a href=""https://kb.hs3.pl/t/377"">Drukarka 3D “Elegoo Neptune 4 Pro”</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', '3d-print']"
514,"<a href=""https://kb.hs3.pl/t/514"">Pistolet do kleju na gorąco</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab'] 514,"<a href=""https://kb.hs3.pl/t/514"">Pistolet do kleju na gorąco</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab']
698,"<a href=""https://kb.hs3.pl/t/698"">Disco betoniarka</a>","<a href=""https://kb.hs3.pl/tag/garage"">garage</a>","['garage', 'projects', 'sticker-needed']" 698,"<a href=""https://kb.hs3.pl/t/698"">Disco betoniarka</a>","<a href=""https://kb.hs3.pl/tag/garage"">garage</a>","['garage', 'projects', 'sticker-needed']"
456,"<a href=""https://kb.hs3.pl/t/456"">Wzmacniacz gitarowy Roland Micro Cube</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
179,"<a href=""https://kb.hs3.pl/t/179"">Sprzęt komp Desktop Dr Robotomy</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 179,"<a href=""https://kb.hs3.pl/t/179"">Sprzęt komp Desktop Dr Robotomy</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
585,"<a href=""https://kb.hs3.pl/t/585"">Ścianka narzędziowa</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab'] 585,"<a href=""https://kb.hs3.pl/t/585"">Ścianka narzędziowa</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab']
584,"<a href=""https://kb.hs3.pl/t/584"">Stojak ze śrubokrętami</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab'] 584,"<a href=""https://kb.hs3.pl/t/584"">Stojak ze śrubokrętami</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>",['lab']
@ -63,12 +78,7 @@ id,title,place,tags
304,"<a href=""https://kb.hs3.pl/t/304"">Monitor LG StudioWorks 560N</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 304,"<a href=""https://kb.hs3.pl/t/304"">Monitor LG StudioWorks 560N</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
554,"<a href=""https://kb.hs3.pl/t/554"">ArcaderOS - Śmieciowy Arcade Charytatywny dla każdego</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 554,"<a href=""https://kb.hs3.pl/t/554"">ArcaderOS - Śmieciowy Arcade Charytatywny dla każdego</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
283,"<a href=""https://kb.hs3.pl/t/283"">Telewizor Funai</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 283,"<a href=""https://kb.hs3.pl/t/283"">Telewizor Funai</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
531,"<a href=""https://kb.hs3.pl/t/531"">Streamer LTO-4 HP M8609A</a>","<a href=""https://kb.hs3.pl/tag/server-room"">server-room</a>",['server-room']
285,"<a href=""https://kb.hs3.pl/t/285"">Konsola do gier Sony PlayStation 2 Slim + kontroler Namco GunCon</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
478,"<a href=""https://kb.hs3.pl/t/478"">Gitara basowa Squier Precision Bass</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab'] 478,"<a href=""https://kb.hs3.pl/t/478"">Gitara basowa Squier Precision Bass</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
479,"<a href=""https://kb.hs3.pl/t/479"">Guitalele Ever Play GT-WBK</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
481,"<a href=""https://kb.hs3.pl/t/481"">Gitara elektryczna Blond STR-1H MN SFG</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
480,"<a href=""https://kb.hs3.pl/t/480"">Gitara elektryczna Blond TE-1 MN BB</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
533,"<a href=""https://kb.hs3.pl/t/533"">Access Point Mikrotik cAP ac</a>",unknown,[] 533,"<a href=""https://kb.hs3.pl/t/533"">Access Point Mikrotik cAP ac</a>",unknown,[]
546,"<a href=""https://kb.hs3.pl/t/546"">Kwadraty ze sklejki w drewnianych pudełkach</a>",unknown,[] 546,"<a href=""https://kb.hs3.pl/t/546"">Kwadraty ze sklejki w drewnianych pudełkach</a>",unknown,[]
545,"<a href=""https://kb.hs3.pl/t/545"">LEGO piedestał z figurkami i jednorożcem</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 545,"<a href=""https://kb.hs3.pl/t/545"">LEGO piedestał z figurkami i jednorożcem</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
@ -81,7 +91,6 @@ id,title,place,tags
536,"<a href=""https://kb.hs3.pl/t/536"">Płyta główna ECS L7VMM3</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 536,"<a href=""https://kb.hs3.pl/t/536"">Płyta główna ECS L7VMM3</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
535,"<a href=""https://kb.hs3.pl/t/535"">Płyta główna EPoX EP-8K9A7I</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 535,"<a href=""https://kb.hs3.pl/t/535"">Płyta główna EPoX EP-8K9A7I</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
92,"<a href=""https://kb.hs3.pl/t/92"">Drukarka 3D Creality K1 Max</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', 'tools', '3d-print']" 92,"<a href=""https://kb.hs3.pl/t/92"">Drukarka 3D Creality K1 Max</a>","<a href=""https://kb.hs3.pl/tag/lab"">lab</a>","['lab', 'tools', '3d-print']"
530,"<a href=""https://kb.hs3.pl/t/530"">Discman SONY</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']
454,"<a href=""https://kb.hs3.pl/t/454"">Perkusja Alesis DM8</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'audiolab']" 454,"<a href=""https://kb.hs3.pl/t/454"">Perkusja Alesis DM8</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>","['cow-work', 'audiolab']"
273,"<a href=""https://kb.hs3.pl/t/273"">Drukarka Samsung ML-3710ND</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work'] 273,"<a href=""https://kb.hs3.pl/t/273"">Drukarka Samsung ML-3710ND</a>","<a href=""https://kb.hs3.pl/tag/cow-work"">cow-work</a>",['cow-work']
476,"<a href=""https://kb.hs3.pl/t/476"">Wieża TECHNICS EH550 - kolumny głośnikowe</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab'] 476,"<a href=""https://kb.hs3.pl/t/476"">Wieża TECHNICS EH550 - kolumny głośnikowe</a>","<a href=""https://kb.hs3.pl/tag/audiolab"">audiolab</a>",['audiolab']

Can't render this file because it contains an unexpected character in line 4 and column 153.