[RELEASE] RexiMemo: Open Source Edition

This commit is contained in:
Rekushi authored and GitHub committed 2026-09-15 23:07:13 +01:00
1 parent 5e2acc31c5
commit a438dfc231
45 files changed
+8516 -169

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
import html
import io
import math
from pathlib import Path
from urllib.parse import urlencode
from PIL import Image
from DB import Database
from Hatenatools import PPM, UGO
from hatena import client_ip, request_args, request_headers, request_path
from reximemo_image_formats import encode_image
ROOT_HOST = "flipnote.hatena.com"
ROOT = Path(__file__).resolve().parent
def region_from_request(request):
for part in request_path(request).split("/"):
if part.startswith("v2-"):
return part[3:]
return "xx"
def host_for_region(region):
return "ugomemo.hatena.ne.jp" if region == "jp" else ROOT_HOST
def dsi_url(request, suffix):
region = region_from_request(request)
return f"http://{host_for_region(region)}/ds/v2-{region}/{str(suffix).lstrip('/')}"
def current_account(request):
return Database.account_for_ip(client_ip(request))
def current_creator(request, *, fsid="", display_name=""):
account = current_account(request)
return Database.get_or_create_creator(
client_ip(request),
int(account["id"]) if account else None,
fsid=fsid,
display_name=display_name,
)
def keyboard_value(request):
return request_headers(request).get("x-email-addr", "").strip()
def dialog(request, message, status=200):
request.setResponseCode(int(status))
request.setHeader(b"X-DSi-Dialog-Type", b"1")
request.setHeader(b"content-type", b"text/plain")
return str(message).encode("utf-16le")
def forward(request, target):
request.setHeader(b"X-DSi-Forwarder", str(target).encode("ascii", "ignore"))
def html_page(request, title, body, extra_head=""):
request.setHeader(b"content-type", b"text/html; charset=utf-8")
page = f"""<!doctype html><html><head><title>{html.escape(str(title))}</title>
<meta name="uppertitle" content="{html.escape(str(title), quote=True)}">
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/common.css">
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/basic.css">
<link rel="stylesheet" type="text/css" href="http://{ROOT_HOST}/css/ds/ose.css">
{extra_head}</head><body><div class="container">{body}</div></body></html>"""
return page.encode("utf-8")
def query_int(request, name, default=1, minimum=1, maximum=1000000):
try:
n = int(request_args(request).get(name, [str(default)])[0])
except (TypeError, ValueError):
n = default
return min(maximum, max(minimum, n))
def make_menu(layout=(4,), title=None, subtitle_top="", subtitle_bottom="", left="", right="", buttons=None, dropdowns=None, corner=None):
ugo = UGO()
ugo.Loaded = True
ugo.Items = [("layout", tuple(layout))]
if title is not None:
ugo.Items.append(("topscreen text", [str(title), str(left), str(right), str(subtitle_top), str(subtitle_bottom)], 0))
for d in dropdowns or []:
ugo.Items.append(("category", d["url"], d["label"], bool(d.get("selected"))))
if corner:
ugo.Items.append(("post", corner["url"], corner["label"]))
embed_index = 0
for b in buttons or []:
if b.get("thumbnail") is not None:
ugo.Items.append((
"button", 3, b.get("label", ""), b["url"],
(str(b.get("stars", 0)), str(b.get("lock", 765)), "573", "0"),
(b.get("embed_name", "thumb.tmb"), b["thumbnail"]),
))
embed_index += 1
elif b.get("icon_ntft") is not None:
icon_data = bytes(b["icon_ntft"])
if len(icon_data) != 2048:
raise ValueError("UGO menu icons must be 32x32 NTFT data")
ugo.Items.append((
"button", int(b.get("icon_trait", embed_index)), b.get("label", ""), b["url"],
("", "0"), (b.get("embed_name", "icon.ntft"), icon_data),
))
embed_index += 1
else:
ugo.Items.append(("button", int(b.get("icon", 100)), b.get("label", ""), b["url"], ("", "0"), None))
return ugo.Pack()
def placeholder_icon(name="browse"):
path = ROOT / "hatenadir" / "images" / "ds" / f"{name}.ntft"
return path.read_bytes()
def flipnote_buttons(request, rows):
buttons = []
for fn in rows:
try:
tmb = Database.GetFlipnoteTMB(fn["creator_fsid"], fn["filename"])
except OSError:
continue
buttons.append({
"label": "",
"url": dsi_url(request, f"movie/{fn['creator_fsid']}/{fn['filename']}.htm"),
"thumbnail": tmb,
"embed_name": fn["filename"] + ".tmb",
"stars": int(fn.get("stars") or 0),
"lock": 765,
})
return buttons
def pagination_buttons(request, base, page, total, per_page=50, params=None):
params = dict(params or {})
max_page = max(1, int(math.ceil(total / float(per_page))))
out = []
if page > 1:
q = dict(params); q["page"] = page - 1
out.append({"label": "Previous Page", "url": dsi_url(request, base) + "?" + urlencode(q), "icon": 116})
if page < max_page:
q = dict(params); q["page"] = page + 1
out.append({"label": "Next Page", "url": dsi_url(request, base) + "?" + urlencode(q), "icon": 115})
return out
def ppm_first_frame_image(raw):
ppm = PPM().Read(raw, ReadFrames=True, ReadSound=False)
if not ppm:
raise ValueError("Invalid PPM")
frame = ppm.GetFrame(0)
if frame is False or frame is None:
raise ValueError("Missing first frame")
data = frame.tobytes(order="F")
return Image.frombytes("RGBA", (len(frame), len(frame[0])), data)
def ppm_thumbnail_png(raw, size=(256, 192)):
image = ppm_first_frame_image(raw).convert("RGB")
image.thumbnail(size, Image.Resampling.NEAREST)
out = io.BytesIO()
image.save(out, "PNG", optimize=True)
return out.getvalue()
def comment_npf(comment_id, width=128, height=64):
raw = Database.comment_ppm(int(comment_id))
if raw is None:
return None
image = ppm_first_frame_image(raw).convert("RGBA")
image = image.resize((int(width), int(height)), Image.Resampling.NEAREST)
return encode_image(image, "npf").data