Compare commits
16 commits
41ad34550b
...
e9f2dec500
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f2dec500 | |||
| 84963b42e2 | |||
| 1ea09e0408 | |||
|
|
cebafd3cec | ||
|
|
2a2b5973df | ||
|
|
3897d37f2a | ||
|
|
6b50d02d60 | ||
|
|
aad7c67d48 | ||
|
|
92bf65dc62 | ||
|
|
c6787d8c2e | ||
|
|
c721e4c909 | ||
|
|
19516d50fc | ||
|
|
cda1ba4f07 | ||
|
|
27798e6e54 | ||
|
|
8c420af0af | ||
|
|
3865dcc402 |
49 changed files with 4907 additions and 1470 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.venv
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
47
.github/workflows/build_docker.yml
vendored
Normal file
47
.github/workflows/build_docker.yml
vendored
Normal 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
20
Dockerfile
Normal 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" ]
|
||||||
19
README.md
19
README.md
|
|
@ -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
12
docker-compose.yml
Normal 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
0
fegen/__init__.py
Normal 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
|
|
@ -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!")
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
BIN
fonts/SourceCodePro-SemiBold.ttf
Normal file
BIN
fonts/SourceCodePro-SemiBold.ttf
Normal file
Binary file not shown.
BIN
img.png
Normal file
BIN
img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
img_1.png
Normal file
BIN
img_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
75
labeler/adapter/cli.py
Normal file
75
labeler/adapter/cli.py
Normal 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)
|
||||||
98
labeler/adapter/fastapi_srv.py
Normal file
98
labeler/adapter/fastapi_srv.py
Normal 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)
|
||||||
82
labeler/adapter/telegram_bot.py
Normal file
82
labeler/adapter/telegram_bot.py
Normal 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
90
labeler/app/labeler.py
Normal 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
125
labeler/domain/objects.py
Normal 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
|
||||||
81
labeler/infra/e550w_printer/media_definitions.py
Normal file
81
labeler/infra/e550w_printer/media_definitions.py
Normal 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)
|
||||||
127
labeler/infra/e550w_printer/printer.py
Normal file
127
labeler/infra/e550w_printer/printer.py
Normal 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
169
labeler/infra/renderer.py
Normal 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
23
labeler/interfaces.py
Normal 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
|
||||||
BIN
labeler_docs/brother/technical_reference_manual.pdf
Normal file
BIN
labeler_docs/brother/technical_reference_manual.pdf
Normal file
Binary file not shown.
36
lbot_readme.md
Normal file
36
lbot_readme.md
Normal 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
|
||||||
|

|
||||||
|
- `/simple_label` - print a simple label
|
||||||
|

|
||||||
|
|
||||||
|
### 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
37
pyproject.toml
Normal 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"
|
||||||
|
|
@ -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
1
tests/conftest.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from fixtures import * # noqa
|
||||||
48
tests/fixtures.py
Normal file
48
tests/fixtures.py
Normal 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
|
||||||
30
tests/labeler/domain/test_objects.py
Normal file
30
tests/labeler/domain/test_objects.py
Normal 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 |
BIN
tests/labeler/infra/test_images/multiline_label_test.png
Normal file
BIN
tests/labeler/infra/test_images/multiline_label_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
tests/labeler/infra/test_images/no_fixed_width.png
Normal file
BIN
tests/labeler/infra/test_images/no_fixed_width.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
tests/labeler/infra/test_images/simple_label_test.png
Normal file
BIN
tests/labeler/infra/test_images/simple_label_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
50
tests/labeler/infra/test_renderer.py
Normal file
50
tests/labeler/infra/test_renderer.py
Normal 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
|
||||||
23
zasoby.csv
23
zasoby.csv
|
|
@ -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.
|
Loading…
Add table
Add a link
Reference in a new issue