Initial commit

This commit is contained in:
Holger Weber 2025-07-15 22:27:59 +02:00
commit ee54dae729
8 changed files with 426 additions and 0 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# Rename file to .env and adapt variables
# Environment variables for book stack update (bsupdate.py)
BOOKSTACK_URL="https://wiki.my-book-stack.net"
BOOKSTACK_TOKEN_ID="token-generated-by-book-stack"
BOOKSTACK_TOKEN_SECRET="token-generated-by-book-stack"
PAGE_SLUG="name-of-page"
BOOK_SLUG="name-of-book"
INSERT_MARKER_LINKS="## Links"
INSERT_MARKER_TEXT="## Text"
# Environment variables for req-short.py
API_TOKEN="something-i-created-by-myself"

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
docker-compose.yml
data/*

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# Basis-Image mit Python
FROM python:3.11-slim
# Installiere Abhängigkeiten
RUN pip install --no-cache-dir --upgrade pip
# Arbeitsverzeichnis im Container
WORKDIR /app
# requirements.txt kopieren und installieren
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App-Dateien kopieren
COPY *.py .
# Port freigeben
EXPOSE 8050
# Container-Startbefehl
CMD ["python", "req-short.py"]

180
bsupdate.py Normal file
View File

@ -0,0 +1,180 @@
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse
import os
BOOKSTACK_URL = os.getenv("BOOKSTACK_URL","https://my-book-stack.com")
BOOKSTACK_TOKEN_ID = os.getenv("BOOKSTACK_TOKEN_ID", "TOKEN-ID")
BOOKSTACK_TOKEN_SECRET = os.getenv("BOOKSTACK_TOKEN_SECRET", "TOKEN-SECRET")
PAGE_SLUG = os.getenv("PAGE_SLUG","page-name-of-book")
BOOK_SLUG = os.getenv("BOOK_SLUG","book-name")
INSERT_MARKER_LINK = os.getenv("INSERT_MARKER_LINK", "## Unsortierte Links")
INSERT_MARKER_TEXT = os.getenv("INSERT_MARKER_TEXT", "## Textmerker")
HEADERS = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Token {BOOKSTACK_TOKEN_ID}:{BOOKSTACK_TOKEN_SECRET}"
}
def is_valid_url(url):
parsed = urlparse(url)
return all([parsed.scheme in ("http", "https"), parsed.netloc])
def get_page_id_by_slug(book_slug, page_slug):
res = requests.get(f"{BOOKSTACK_URL}/api/pages", headers=HEADERS)
print(HEADERS)
print(res)
pages = res.json().get("data", [])
for p in pages:
if p["book_slug"] == book_slug and p["slug"] == page_slug:
return p["id"]
raise("Book not found")
def get_page_title_from_url(url):
try:
response = requests.get(url, timeout=10)
soup = BeautifulSoup(response.text, "html.parser")
title_tag = soup.find("title")
return title_tag.text.strip() if title_tag else url
except Exception as e:
print(f"Fehler beim Abrufen des Titels: {e}")
return url
def get_clean_title_from_url(url, max_len=80):
try:
res = requests.get(url, timeout=10)
res.raise_for_status()
soup = BeautifulSoup(res.text, "html.parser")
# 1. og:title
og_title = soup.find("meta", property="og:title")
if og_title and og_title.get("content"):
title = og_title["content"]
# 2. <meta name="title">
elif soup.find("meta", attrs={"name": "title"}):
title = soup.find("meta", attrs={"name": "title"}).get("content", "")
# 3. <title>
elif soup.title and soup.title.string:
title = soup.title.string
# 4. Fallback: Domain
else:
return urlparse(url).netloc
title = title.strip()
# Optional: Aufteilen an Trennzeichen wie " | " oder " "
for sep in ["|", "", "-", ""]:
if sep in title:
title = title.split(sep)[0].strip()
break
# Länge kürzen
if len(title) > max_len:
title = title[:max_len].rstrip() + ""
return title
except Exception as e:
print(f"Fehler beim Abrufen oder Parsen des Titels: {e}")
return urlparse(url).netloc # Fallback auf Domain
def append_link_to_bookstack_page(url):
strPageId = get_page_id_by_slug(BOOK_SLUG, PAGE_SLUG)
# Hole aktuellen Seiteninhalt
page = requests.get(f"{BOOKSTACK_URL}/api/pages/{strPageId}", headers=HEADERS).json()
current_content = page["markdown"]
if url in current_content:
print("Text bereits vorhanden.")
return
title = get_page_title_from_url(url)
new_entry = f"- [{title}]({url})<br>{url}"
# Füge Link unterhalb von INSERT_MARKER_LINK ein
if INSERT_MARKER_LINK in current_content:
parts = current_content.split(INSERT_MARKER_LINK)
updated_content = parts[0] + INSERT_MARKER_LINK + "\n" + new_entry + parts[1]
else:
# Falls Marker nicht vorhanden, füge ihn am Ende hinzu
updated_content = current_content + "\n\n## Unsortierte Links\n\n" + new_entry
# Seite aktualisieren
update_data = {
"name": page["name"],
"markdown": updated_content
}
response = requests.put(
f"{BOOKSTACK_URL}/api/pages/{strPageId}",
headers=HEADERS,
json=update_data
)
if response.status_code == 200:
print("Link erfolgreich hinzugefügt.")
else:
print("Fehler beim Aktualisieren der Seite:", response.text)
def append_text_to_bockstack_page(strText):
strPageId = get_page_id_by_slug(BOOK_SLUG, PAGE_SLUG)
# Hole aktuellen Seiteninhalt
page = requests.get(f"{BOOKSTACK_URL}/api/pages/{strPageId}", headers=HEADERS).json()
current_content = page["markdown"]
if strText in current_content:
print("Text bereits vorhanden.")
return
new_entry = strText + "\n\n"
# Füge Text unterhalb von INSERT_MARKER_TEXT ein
if INSERT_MARKER_TEXT in current_content:
parts = current_content.split(INSERT_MARKER_TEXT)
updated_content = parts[0] + INSERT_MARKER_TEXT + "\n\n" + new_entry + "\n" + parts[1]
else:
# Falls Marker nicht vorhanden, füge ihn am Ende hinzu
updated_content = current_content + "\n\n## Textmerker\n\n" + new_entry
# Seite aktualisieren
update_data = {
"name": page["name"],
"markdown": updated_content
}
response = requests.put(
f"{BOOKSTACK_URL}/api/pages/{strPageId}",
headers=HEADERS,
json=update_data
)
if response.status_code == 200:
print("Link erfolgreich hinzugefügt.")
else:
print("Fehler beim Aktualisieren der Seite:", response.text)
def add_link_or_text(strText):
if (is_valid_url(strText)):
append_link_to_bookstack_page(strText)
else:
append_text_to_bockstack_page(strText)
pass

31
docker-compose.example Normal file
View File

@ -0,0 +1,31 @@
# adapt file to your needs especially traefik (reverse proxy options)
# rename file to docker-compose.yml
services:
req_short:
build:
context: .
dockerfile: Dockerfile
container_name: req_short
env_file:
- .env
#ports:
# - "8050:8050"
volumes:
- ./data/uploads:/app/uploads
restart: unless-stopped
labels:
# if using traefik
- "traefik.http.routers.wiki.rule=Host(`wiki.my-book-stack.com`)"
- "traefik.http.routers.wiki.tls.certResolver=default"
- "traefik.http.routers.wiki.tls=true"
- "traefik.http.services.wiki.loadbalancer.server.port=8050"
# network needed if using traefik
networks:
- router
# only needed for traefik
networks:
router:
name: router-network
external: true

83
readme.md Normal file
View File

@ -0,0 +1,83 @@
Ich möchte eine Möglichkeit haben, Informationen von meinem Smartphone aus mit dem Wiki oder etwas anderem zu teilen.
Generelle Aufgaben:
- Link aus Webbrowser teilen -&gt; automatische Eintragung ins Wiki
- Datei aus z.B. Fotoverwaltung teilen -&gt; autmoatische Ablage auf dem Server
- Wiki-Artikel schneller erstellen (Bilder)
## Konzept
- Teilen über Android mittels App [HTTP Request Shortcuts](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts)
- Python Gegenstück auf Server um Dateien und Texte entgegen zu nehmen (Flask)
### Konfiguration HTTP Request Shortcuts
Für das Basisbeispiel in welchem wir zum einen Dateien und zum anderen Links bzw. Texte teilen möchten, benötigen wir zwei Konfigurationseinträge bzw. HTTP Shortcuts.
#### Datei teilen
Sobald dieser Eintrag in die App hinzugefügt wurde, kann jede Datei auf den Server übertragen werden. Hierzu muss nur in der entsprechenden App "Teilen" ausgewählt und als Ziel HTTP Request Shortcuts gewählt werden.
1. Neuen Eintrag über + hinzufügen
2. Auswahl *Von Grund auf erstellen*
3. Shortcut-Name: *File to Server*
4. Allgemeine Einstellungen:
1. Methode: *Post*
2. URL: https://https://my-book-stack.com/upload
5. Request Header:
1. mittels + Eintrag für API-Token hinzufügen (siehe API_TOKEN Umgebungsvariable)
2. Header: *X-API-Token*
3. Wert: *my-secret-token*
6. Request Body:
1. Request-Body-Typ: *Parameterliste (form-data)*
2. Eintrat mit + hinzufügen
1. *Einzelne Datei* auswählen
2. Parametername: *file*
3. Daten-Quelle: *Dateiauswahl öffnen*
7. Response-Einstellungen - Was passiert, wenn das Teilen ausgeführt wurde?
1. Bei Erfolg: *Benutzerdefinierte Nachricht anzeigen*
2. Nachricht: *Wurde mit Server geteilt.*
3. Anzeige-Typ: *Toast-PopUp*
Alle nicht aufgeführten Einstellungen entsprechen den Standardeinstellungen und wurden nicht angepasst.
#### Text / Link teilen
Um einen Text teilen zu können, muss zuvor noch eine Variable angelegt werden.
1. drei Punkte oben Rechts auswählen (App)
2. *{} Variablen* auswählen
3. mit + neue Variable hinzufügen
4. *Statische Variable* wählen
1. Name: *shared_text*
2. *"Teilen..."* erlauben anwählen
Im Anschluss einen neuen Eintrag hinzufügen. Dies geht am schnellsten in dem man den bereits angelegten Shortcut Dupliziert.
1. vorhandenen Eintrag lange drücken
2. Im Kontextmenü *Duplizieren* auswählen
3. Shortcut-Name ändern z.B.: *Link to Server*
4. Request-Body anpassen:
1. Vorhandenen Eintrag löschen
2. Neuen Eintrag über + hinzufügen
1. Parametertyp: *Text*
2. Parametername: *text*
3. Wert: {{shared_text}} (mit allen Klammern)
Nun sollte es möglich sein, auch aus dem Browser heraus direkt einen Link zu teilen.
### Python Gegenstück
Das Programm req-short.py hat die folgenden Aufgaben:
- Kann Dateien am Endpunkt /upload entgegennehmen und im Ordner uploads speichern
- Kann einen Text am Endpunkt /upload entgegennehmen und speichert diesen in log.txt
- Zeigt eine Galerie der hochgeladenen Dateien bzw. nur der Bilder an
- Der Upload ist über ein "API_TOKEN" gesichert (nur Basis)
Die Anwendung muss hinter einem Reverse-Proxy gehostet werden, um einen sicheren Zugriff über SSL zu haben.
Um einen geteilten Link bzw. einen Text in Book Stack zu hinterlegen wird bsupdate.py verwendet. Hierzu müssen die .env Variablen gesetzt werden.

93
req-short.py Normal file
View File

@ -0,0 +1,93 @@
from flask import Flask, request, jsonify, abort, send_from_directory, render_template_string
import os
from datetime import datetime
from werkzeug.utils import secure_filename
import bsupdate as bu
app = Flask(__name__)
UPLOAD_FOLDER = "UPLOAD_FOLDER"
API_TOKEN = os.getenv("API_TOKEN", "my-secret-token")
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# Authentifizierung
def check_auth():
token = request.headers.get("X-API-Token")
if token != API_TOKEN:
abort(401, description="Unauthorized")
# Dateiendung prüfen
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# Upload-Endpunkt
@app.route("/upload", methods=["POST"])
def upload():
check_auth()
text = request.form.get("text")
uploaded_file = request.files.get("file")
if uploaded_file and uploaded_file.filename != "":
filename = secure_filename(uploaded_file.filename)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
full_filename = f"{timestamp}_{filename}"
path = os.path.join(app.config["UPLOAD_FOLDER"], full_filename)
uploaded_file.save(path)
print(f"Gespeichert: {path}")
else:
print("Keine Datei erhalten.")
if text:
bu.add_link_or_text(text)
# with open(os.path.join(app.config["UPLOAD_FOLDER"], "log.txt"), "a") as f:
# f.write(f"{datetime.now()}: {text}\n")
print(f"Text erhalten: {text}")
return jsonify({"status": "OK"}), 200
# Galerie anzeigen
@app.route("/")
def gallery():
files = os.listdir(app.config["UPLOAD_FOLDER"])
image_files = [
f for f in sorted(files, reverse=True)
if allowed_file(f)
]
html = render_template_string("""
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Bilder-Galerie</title>
<style>
body { font-family: sans-serif; padding: 2em; }
img { max-width: 300px; margin: 1em; }
.grid { display: flex; flex-wrap: wrap; }
</style>
</head>
<body>
<h1>Hochgeladene Bilder</h1>
<div class="grid">
{% for filename in images %}
<div>
<img src="/uploads/{{ filename }}" alt="{{ filename }}">
<p>{{ filename }}</p>
</div>
{% endfor %}
</div>
</body>
</html>
""", images=image_files)
return html
# Bilder statisch servieren
@app.route("/uploads/<path:filename>")
def uploaded_file(filename):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8050)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask
requests
beautifulsoup4