django-tgcms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-tgcms
3
+ Version: 0.1.0
4
+ Summary: Django reusable app: a Telegram post constructor that outputs Bot API-ready {text, entities} payloads.
5
+ Project-URL: Homepage, https://gitlab.com/ddnsupp/django-tgcms
6
+ Author: ddnsupp
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: bot-api,cms,django,entities,telegram
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 4.2
13
+ Classifier: Framework :: Django :: 5.0
14
+ Classifier: Framework :: Django :: 5.1
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Framework :: Django :: 6.0
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: django>=4.2
23
+ Description-Content-Type: text/markdown
24
+
25
+ # django-tgcms
26
+
27
+ A reusable Django app for building Telegram posts. Compose posts from blocks
28
+ (heading, formatted text, photo, video) using a WYSIWYG editor embedded in
29
+ Django admin. `Post.render()` returns a Bot API-ready payload — sending is
30
+ entirely up to you.
31
+
32
+ **No dependencies beyond Django. No bot logic, no HTTP calls.**
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install django-tgcms
40
+ # or
41
+ uv add django-tgcms
42
+ ```
43
+
44
+ `settings.py`:
45
+
46
+ ```python
47
+ INSTALLED_APPS = [
48
+ ...
49
+ "tgcms",
50
+ ]
51
+
52
+ # Optional — only needed for the send_post test command
53
+ TGCMS = {
54
+ "BOT_TOKEN": env("YOUR_BOT_TOKEN"), # map whatever name your project uses
55
+ }
56
+ ```
57
+
58
+ `urls.py` (only if you need the built-in views):
59
+
60
+ ```python
61
+ urlpatterns += [
62
+ path("tg/", include("tgcms.urls")),
63
+ ]
64
+ ```
65
+
66
+ Run migrations:
67
+
68
+ ```bash
69
+ python manage.py migrate
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Content — Django admin
75
+
76
+ Posts are edited in the standard Django admin at `/admin/tgcms/post/`.
77
+
78
+ **Block types** (drag-and-drop reordering inside each post):
79
+
80
+ | Type | Stores |
81
+ |---|---|
82
+ | `heading` | Plain text title |
83
+ | `text` | Formatted text — bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, links |
84
+ | `photo` | Media asset + caption |
85
+ | `video` | Media asset + caption |
86
+
87
+ **MediaAsset** (`/admin/tgcms/mediaasset/`) is a shared media registry. One
88
+ asset can be referenced by any number of blocks across any number of posts.
89
+ After the first Telegram send the `telegram_file_id` is cached on the asset —
90
+ subsequent sends reuse it without re-uploading.
91
+
92
+ ---
93
+
94
+ ## Bot integration
95
+
96
+ ```python
97
+ from tgcms.models import Post
98
+
99
+ post = Post.objects.prefetch_related("blocks__media").get(pk=post_id)
100
+ payload = post.render()
101
+ # {
102
+ # "blocks": [
103
+ # {"type": "heading", "text": "Title"},
104
+ # {"type": "text", "text": "Hello!", "entities": [{"type": "bold", "offset": 0, "length": 5}]},
105
+ # {"type": "photo", "media_asset_id": 3, "file": "AgACAgI...", "caption": "..."},
106
+ # ]
107
+ # }
108
+ ```
109
+
110
+ **aiogram broadcast pattern:**
111
+
112
+ ```python
113
+ from asgiref.sync import sync_to_async
114
+
115
+ async def send_post(bot, chat_id: int, post_id: int):
116
+ post = await sync_to_async(
117
+ Post.objects.prefetch_related("blocks__media").get
118
+ )(pk=post_id)
119
+
120
+ for block in post.blocks.all():
121
+ data = block.render()
122
+
123
+ if data["type"] == "heading":
124
+ await bot.send_message(chat_id, f"<b>{data['text']}</b>", parse_mode="HTML")
125
+
126
+ elif data["type"] == "text":
127
+ await bot.send_message(chat_id, data["text"])
128
+
129
+ elif data["type"] == "photo":
130
+ msg = await bot.send_photo(
131
+ chat_id,
132
+ photo=data["file"], # telegram_file_id, S3 URL, or local path
133
+ caption=data.get("caption"),
134
+ )
135
+ # Cache file_id after first upload — all future renders return it
136
+ if block.media and not block.media.telegram_file_id:
137
+ await sync_to_async(block.media.cache_file_id)(msg.photo[-1].file_id)
138
+
139
+ elif data["type"] == "video":
140
+ msg = await bot.send_video(chat_id, video=data["file"], caption=data.get("caption"))
141
+ if block.media and not block.media.telegram_file_id:
142
+ await sync_to_async(block.media.cache_file_id)(msg.video.file_id)
143
+ ```
144
+
145
+ Once `cache_file_id()` is called, `block.media.source` returns the cached
146
+ `telegram_file_id` for every subsequent post that references the same asset.
147
+
148
+ ---
149
+
150
+ ## Testing — management command
151
+
152
+ ```bash
153
+ # Token is read from settings.TGCMS["BOT_TOKEN"] automatically
154
+ python manage.py send_post <post_id> <chat_id>
155
+
156
+ # Or pass it explicitly
157
+ python manage.py send_post 1 @mychannel --token 123456:ABC...
158
+
159
+ # Or via env var
160
+ TELEGRAM_BOT_TOKEN=123456:ABC... python manage.py send_post 1 123456789
161
+ ```
162
+
163
+ Token lookup order: `--token` → `settings.TGCMS["BOT_TOKEN"]` → `TELEGRAM_BOT_TOKEN` env var.
164
+
165
+ ---
166
+
167
+ ## Models
168
+
169
+ ```
170
+ MediaAsset
171
+ file FileField — upload from disk
172
+ file_url URLField — S3 / CDN link
173
+ telegram_file_id Cached after first send (read-only in admin)
174
+ .source Property: returns the best available file reference
175
+ .cache_file_id() Persists telegram_file_id; call once after the first send
176
+
177
+ Post
178
+ title, status draft / published
179
+ .render() Returns {"blocks": [...]}
180
+ .mark_published() Sets status and published_at
181
+
182
+ Block FK → Post, FK → MediaAsset (nullable)
183
+ type heading / text / photo / video
184
+ order Managed by drag-and-drop in admin
185
+ text, entities heading and text blocks
186
+ media FK → MediaAsset, photo and video blocks
187
+ caption, caption_entities
188
+ .render() Returns one block in Bot API format
189
+ ```
190
+
191
+ ---
192
+
193
+ ## UTF-16 offsets
194
+
195
+ `MessageEntity.offset` and `length` are counted in UTF-16 code units, not
196
+ Python characters. Non-BMP characters (e.g. 😀 U+1F600) occupy 2 units, not 1.
197
+ All offset arithmetic in `tgcms.formatting` goes through `utf16_len()`.
198
+
199
+ ---
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,31 @@
1
+ tgcms/__init__.py,sha256=uidcTMJ2Hf2gTlLmV-xlhpGK_14enIZ7j4YJF35KCgU,156
2
+ tgcms/admin.py,sha256=Wwd13CO1cTmrmajw3L9IAaOKt588t6hMXQtmNxn47-Y,1897
3
+ tgcms/apps.py,sha256=6PSMngrAbcc2U0vNIgTTJqA_AVva243seEeEVHgM_NM,176
4
+ tgcms/conf.py,sha256=zEnJXUaX2BqJ90bRlWZQHcA8WfsRrNcXsJF1qgxeNio,500
5
+ tgcms/forms.py,sha256=70lctbvBnET6FPl5gWsveF6frWviJ6WaQTnVl64Y4Sc,2076
6
+ tgcms/models.py,sha256=G6Ou7sarjVi8yd7mTcBh7h4Xy3uv0fgSELDM0ZV4UIo,4380
7
+ tgcms/urls.py,sha256=oVGslz6aoplONMANLjv0Ml5vbB_1QzXuaLSdqDjtJNc,397
8
+ tgcms/views.py,sha256=Y2Mv71yrCmd8_dL1fHJnC4K-bzAih0g5OgQpEqMaIUA,1409
9
+ tgcms/widgets.py,sha256=9NrZa2lYVejPkV53YAjbPho4pXFX079ZYLsZB5pl1KU,2155
10
+ tgcms/formatting/__init__.py,sha256=MNOIowE0hzrcVBQNCNLhfTMz5jaiGKYF994zS6goiwU,510
11
+ tgcms/formatting/entities.py,sha256=rl2huXcnI2Z3vLBtaSXNm2KPK9fwzcuhrfcND3KM2DI,602
12
+ tgcms/formatting/html.py,sha256=WVKY83VWMn_16L2GWKQDZEnUlMe2piwLnMMfBggJig4,6003
13
+ tgcms/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ tgcms/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ tgcms/management/commands/send_post.py,sha256=IuiyYB6a0tyJzYRTuzm6Qr5fS-3JFOW3Cdrk1ToadK8,5125
16
+ tgcms/migrations/0001_initial.py,sha256=Gka6mAlL_LZUiAb33PpXJ2wuBSKNQ0YDbV3nnCYCMMA,1217
17
+ tgcms/migrations/0002_remove_post_entities_remove_post_text_and_more.py,sha256=UY9qgfWN4Sj5vHUpsChBdm0Fco56LS0rjjb0PguMkQ8,1674
18
+ tgcms/migrations/0003_block_file_url_block_telegram_file_id.py,sha256=XNUG-0wFmbolLgiUSPyhzqmmYt8ZwPZLSxrfBHh2isA,641
19
+ tgcms/migrations/0004_mediaasset_remove_block_file_remove_block_file_url_and_more.py,sha256=YG5HEukPIUUaAD-7GVm2_ISjcoFCRneeDuvYd4JUBII,1585
20
+ tgcms/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ tgcms/static/tgcms/block_inline.js,sha256=96Q_w7ro_23cbzpZQdcHfsb73-8eet2Bmcck6aVSjmY,6539
22
+ tgcms/static/tgcms/editor.css,sha256=8S0t_2FkxU1DPCu3RH_JpTHXfhcJ4e16--CyhaZouuE,3266
23
+ tgcms/static/tgcms/editor.js,sha256=lqLhMMmcwsjK_Vk7zIMuPOPxvUATRZoLQPeEPrKbeqE,2609
24
+ tgcms/templates/tgcms/base.html,sha256=9Lubq75QXFacJY2sWZyUb7qXU9b4aqwkhM9g2--GlMU,431
25
+ tgcms/templates/tgcms/post_form.html,sha256=7QnIeJLISklQZBgNAUegtSZLFs02HCshAnXwLx9gjP4,1988
26
+ tgcms/templates/tgcms/post_list.html,sha256=p5O6Facxpw8WK2GYRyrXdyZeKW_fCp4HpLC4s6dyrpA,585
27
+ tgcms/templates/tgcms/post_published.html,sha256=qL8YFUo9bkoVs258OXAI2MK8XUP0wmCK-cF8QUw0Q2U,490
28
+ django_tgcms-0.1.0.dist-info/METADATA,sha256=53AVa9WxqBQUQJnaLfm3gTG9YpaVGSQo-EQyJFnCuYc,5845
29
+ django_tgcms-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
30
+ django_tgcms-0.1.0.dist-info/licenses/LICENSE,sha256=a9QcPC5WwLGhHPFm0vuD0qhMHsy4JiRE8XjChhGvAkE,1064
31
+ django_tgcms-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ddnsupp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tgcms/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """django-tgcms: a Telegram post constructor for Django.
2
+
3
+ The import package is ``tgcms``; the distribution is ``django-tgcms``.
4
+ """
5
+
6
+ __version__ = "0.1.0"
tgcms/admin.py ADDED
@@ -0,0 +1,60 @@
1
+ from django.contrib import admin
2
+
3
+ from .forms import BlockForm, PostForm
4
+ from .models import Block, MediaAsset, Post
5
+
6
+
7
+ @admin.register(MediaAsset)
8
+ class MediaAssetAdmin(admin.ModelAdmin):
9
+ list_display = ("__str__", "has_file_id", "created_at")
10
+ search_fields = ("name", "file_url", "telegram_file_id")
11
+ readonly_fields = ("telegram_file_id", "created_at")
12
+ fields = ("name", "file", "file_url", "telegram_file_id", "created_at")
13
+
14
+ @admin.display(description="Cached", boolean=True)
15
+ def has_file_id(self, obj):
16
+ return bool(obj.telegram_file_id)
17
+
18
+
19
+ class BlockInline(admin.StackedInline):
20
+ model = Block
21
+ form = BlockForm
22
+ extra = 1
23
+ fields = ("order", "type", "content_html", "media", "caption_html")
24
+ autocomplete_fields = ("media",)
25
+
26
+ class Media:
27
+ css = {"all": ("tgcms/editor.css",)}
28
+ js = ("tgcms/block_inline.js",)
29
+
30
+
31
+ @admin.register(Post)
32
+ class PostAdmin(admin.ModelAdmin):
33
+ form = PostForm
34
+ inlines = [BlockInline]
35
+ list_display = ("__str__", "status", "block_count", "updated_at", "published_at")
36
+ list_filter = ("status",)
37
+ search_fields = ("title",)
38
+ actions = ["publish_selected"]
39
+ fieldsets = [
40
+ (None, {"fields": ("title", "status")}),
41
+ (
42
+ "Timestamps",
43
+ {
44
+ "fields": ("created_at", "updated_at", "published_at"),
45
+ "classes": ("collapse",),
46
+ },
47
+ ),
48
+ ]
49
+ readonly_fields = ("created_at", "updated_at", "published_at")
50
+
51
+ @admin.display(description="Blocks")
52
+ def block_count(self, obj):
53
+ return obj.blocks.count()
54
+
55
+ @admin.action(description="Mark selected posts as published")
56
+ def publish_selected(self, request, queryset):
57
+ count = queryset.count()
58
+ for post in queryset:
59
+ post.mark_published()
60
+ self.message_user(request, f"{count} post(s) marked as published.")
tgcms/apps.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TgcmsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "tgcms"
7
+ verbose_name = "Telegram CMS"
tgcms/conf.py ADDED
@@ -0,0 +1,21 @@
1
+ from django.conf import settings as django_settings
2
+
3
+ _DEFAULTS = {
4
+ "BOT_TOKEN": "",
5
+ }
6
+
7
+
8
+ class _AppSettings:
9
+ @property
10
+ def _user(self) -> dict:
11
+ return getattr(django_settings, "TGCMS", {})
12
+
13
+ def __getattr__(self, key: str):
14
+ if key.startswith("_"):
15
+ raise AttributeError(key)
16
+ if key not in _DEFAULTS:
17
+ raise AttributeError(f"Unknown TGCMS setting: {key!r}")
18
+ return self._user.get(key, _DEFAULTS[key])
19
+
20
+
21
+ app_settings = _AppSettings()
@@ -0,0 +1,16 @@
1
+ """Pure-Python formatting core for tgcms.
2
+
3
+ This subpackage has no Django dependency. It converts between Telegram-flavoured
4
+ HTML and the canonical ``(text, entities)`` model used by the Bot API, computing
5
+ all ``offset``/``length`` values in UTF-16 code units (as the Bot API requires).
6
+ """
7
+
8
+ from .entities import utf16_len, ENTITY_TYPES
9
+ from .html import html_to_text_entities, text_entities_to_html
10
+
11
+ __all__ = [
12
+ "utf16_len",
13
+ "ENTITY_TYPES",
14
+ "html_to_text_entities",
15
+ "text_entities_to_html",
16
+ ]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ ENTITY_TYPES = frozenset(
4
+ {
5
+ "bold",
6
+ "italic",
7
+ "underline",
8
+ "strikethrough",
9
+ "spoiler",
10
+ "code",
11
+ "pre",
12
+ "text_link",
13
+ "blockquote",
14
+ "expandable_blockquote",
15
+ "custom_emoji",
16
+ }
17
+ )
18
+
19
+
20
+ def utf16_len(text: str) -> int:
21
+ return len(text.encode("utf-16-le")) // 2
22
+
23
+
24
+ def char_offsets(text: str) -> list[int]:
25
+ offsets: list[int] = []
26
+ cu = 0
27
+ for ch in text:
28
+ offsets.append(cu)
29
+ cu += 2 if ord(ch) > 0xFFFF else 1
30
+ offsets.append(cu)
31
+ return offsets
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import html as _html
4
+ from html.parser import HTMLParser
5
+
6
+ from .entities import char_offsets, utf16_len
7
+
8
+ _SIMPLE_TAGS = {
9
+ "b": "bold",
10
+ "strong": "bold",
11
+ "i": "italic",
12
+ "em": "italic",
13
+ "u": "underline",
14
+ "ins": "underline",
15
+ "s": "strikethrough",
16
+ "strike": "strikethrough",
17
+ "del": "strikethrough",
18
+ "code": "code",
19
+ "tg-spoiler": "spoiler",
20
+ }
21
+
22
+ _SIMPLE_OUTPUT = {
23
+ "bold": ("<b>", "</b>"),
24
+ "italic": ("<i>", "</i>"),
25
+ "underline": ("<u>", "</u>"),
26
+ "strikethrough": ("<s>", "</s>"),
27
+ "spoiler": ('<span class="tg-spoiler">', "</span>"),
28
+ "code": ("<code>", "</code>"),
29
+ }
30
+
31
+
32
+ class _Parser(HTMLParser):
33
+ def __init__(self) -> None:
34
+ super().__init__(convert_charrefs=True)
35
+ self.text_parts: list[str] = []
36
+ self._len = 0
37
+ self._open: list[dict] = []
38
+ self.entities: list[dict] = []
39
+ self._code_stack: list[bool] = []
40
+
41
+ def _push(self, etype: str, **extra) -> None:
42
+ self._open.append({"type": etype, "offset": self._len, **extra})
43
+
44
+ def _pop(self, etype: str) -> None:
45
+ for i in range(len(self._open) - 1, -1, -1):
46
+ if self._open[i]["type"] == etype:
47
+ ent = self._open.pop(i)
48
+ ent["length"] = self._len - ent["offset"]
49
+ if ent["length"] > 0:
50
+ self.entities.append(ent)
51
+ return
52
+
53
+ def handle_starttag(self, tag, attrs):
54
+ attrs = dict(attrs)
55
+ if tag == "code":
56
+ lang = attrs.get("class", "")
57
+ if lang.startswith("language-"):
58
+ for ent in reversed(self._open):
59
+ if ent["type"] == "pre":
60
+ ent["language"] = lang[len("language-"):]
61
+ break
62
+ self._code_stack.append(True)
63
+ return
64
+ self._code_stack.append(False)
65
+ self._push("code")
66
+ return
67
+ if tag in _SIMPLE_TAGS:
68
+ self._push(_SIMPLE_TAGS[tag])
69
+ elif tag == "span" and "tg-spoiler" in (attrs.get("class") or "").split():
70
+ self._push("spoiler")
71
+ elif tag == "a":
72
+ self._push("text_link", url=attrs.get("href", ""))
73
+ elif tag == "pre":
74
+ self._push("pre")
75
+ elif tag == "blockquote":
76
+ etype = (
77
+ "expandable_blockquote"
78
+ if "expandable" in attrs
79
+ else "blockquote"
80
+ )
81
+ self._push(etype)
82
+ elif tag == "tg-emoji":
83
+ self._push("custom_emoji", custom_emoji_id=attrs.get("emoji-id", ""))
84
+
85
+ def handle_endtag(self, tag):
86
+ if tag == "code":
87
+ was_lang = self._code_stack.pop() if self._code_stack else False
88
+ if not was_lang:
89
+ self._pop("code")
90
+ return
91
+ if tag in _SIMPLE_TAGS:
92
+ self._pop(_SIMPLE_TAGS[tag])
93
+ elif tag == "span":
94
+ self._pop("spoiler")
95
+ elif tag == "a":
96
+ self._pop("text_link")
97
+ elif tag == "pre":
98
+ self._pop("pre")
99
+ elif tag == "blockquote":
100
+ for ent in reversed(self._open):
101
+ if ent["type"] in ("blockquote", "expandable_blockquote"):
102
+ self._pop(ent["type"])
103
+ break
104
+ elif tag == "tg-emoji":
105
+ self._pop("custom_emoji")
106
+
107
+ def handle_data(self, data):
108
+ self.text_parts.append(data)
109
+ self._len += utf16_len(data)
110
+
111
+ def handle_startendtag(self, tag, attrs):
112
+ if tag == "br":
113
+ self.handle_data("\n")
114
+
115
+
116
+ def html_to_text_entities(html_text: str) -> tuple[str, list[dict]]:
117
+ parser = _Parser()
118
+ parser.feed(html_text or "")
119
+ parser.close()
120
+ text = "".join(parser.text_parts)
121
+ entities = sorted(parser.entities, key=lambda e: (e["offset"], e["length"]))
122
+ return text, entities
123
+
124
+
125
+ def _open_close(entity: dict) -> tuple[str, str]:
126
+ etype = entity["type"]
127
+ if etype in _SIMPLE_OUTPUT:
128
+ return _SIMPLE_OUTPUT[etype]
129
+ if etype == "text_link":
130
+ href = _html.escape(entity.get("url", ""), quote=True)
131
+ return f'<a href="{href}">', "</a>"
132
+ if etype == "pre":
133
+ lang = entity.get("language")
134
+ if lang:
135
+ cls = _html.escape(lang, quote=True)
136
+ return f'<pre><code class="language-{cls}">', "</code></pre>"
137
+ return ("<pre>", "</pre>")
138
+ if etype == "blockquote":
139
+ return ("<blockquote>", "</blockquote>")
140
+ if etype == "expandable_blockquote":
141
+ return ("<blockquote expandable>", "</blockquote>")
142
+ if etype == "custom_emoji":
143
+ eid = _html.escape(str(entity.get("custom_emoji_id", "")), quote=True)
144
+ return f'<tg-emoji emoji-id="{eid}">', "</tg-emoji>"
145
+ return ("", "")
146
+
147
+
148
+ def text_entities_to_html(text: str, entities: list[dict]) -> str:
149
+ if not text:
150
+ return ""
151
+ offsets = char_offsets(text)
152
+ u16_to_idx: dict[int, int] = {}
153
+ for idx, off in enumerate(offsets):
154
+ u16_to_idx.setdefault(off, idx)
155
+
156
+ opens: dict[int, list[dict]] = {}
157
+ closes: dict[int, list[dict]] = {}
158
+ for ent in entities or []:
159
+ start = ent["offset"]
160
+ end = ent["offset"] + ent["length"]
161
+ if start not in u16_to_idx or end not in u16_to_idx:
162
+ continue
163
+ opens.setdefault(start, []).append(ent)
164
+ closes.setdefault(end, []).append(ent)
165
+
166
+ for lst in opens.values():
167
+ lst.sort(key=lambda e: e["offset"] + e["length"], reverse=True)
168
+ for lst in closes.values():
169
+ lst.sort(key=lambda e: e["offset"], reverse=True)
170
+
171
+ out: list[str] = []
172
+ for idx, ch in enumerate(text):
173
+ off = offsets[idx]
174
+ for ent in closes.get(off, []):
175
+ out.append(_open_close(ent)[1])
176
+ for ent in opens.get(off, []):
177
+ out.append(_open_close(ent)[0])
178
+ out.append(_html.escape(ch, quote=False))
179
+ for ent in closes.get(offsets[-1], []):
180
+ out.append(_open_close(ent)[1])
181
+ return "".join(out)
tgcms/forms.py ADDED
@@ -0,0 +1,60 @@
1
+ from django import forms
2
+
3
+ from .formatting import html_to_text_entities, text_entities_to_html
4
+ from .models import Block, Post
5
+ from .widgets import TelegramEditorWidget
6
+
7
+
8
+ class PostForm(forms.ModelForm):
9
+ class Meta:
10
+ model = Post
11
+ fields = ["title", "status"]
12
+
13
+
14
+ class BlockForm(forms.ModelForm):
15
+ content_html = forms.CharField(
16
+ required=False,
17
+ widget=TelegramEditorWidget,
18
+ label="Content",
19
+ help_text="Used for text and heading blocks.",
20
+ )
21
+ caption_html = forms.CharField(
22
+ required=False,
23
+ widget=TelegramEditorWidget,
24
+ label="Caption",
25
+ help_text="Used for photo and video blocks.",
26
+ )
27
+
28
+ class Meta:
29
+ model = Block
30
+ fields = ["order", "type", "media"]
31
+
32
+ def __init__(self, *args, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ if self.instance and self.instance.pk:
35
+ if self.instance.type in (Block.Type.TEXT, Block.Type.HEADING):
36
+ self.fields["content_html"].initial = text_entities_to_html(
37
+ self.instance.text, self.instance.entities
38
+ )
39
+ if self.instance.type in (Block.Type.PHOTO, Block.Type.VIDEO):
40
+ self.fields["caption_html"].initial = text_entities_to_html(
41
+ self.instance.caption, self.instance.caption_entities
42
+ )
43
+
44
+ def save(self, commit=True):
45
+ block = super().save(commit=False)
46
+ if block.type in (Block.Type.TEXT, Block.Type.HEADING):
47
+ text, entities = html_to_text_entities(
48
+ self.cleaned_data.get("content_html", "")
49
+ )
50
+ block.text = text
51
+ block.entities = entities if block.type == Block.Type.TEXT else []
52
+ if block.type in (Block.Type.PHOTO, Block.Type.VIDEO):
53
+ text, entities = html_to_text_entities(
54
+ self.cleaned_data.get("caption_html", "")
55
+ )
56
+ block.caption = text
57
+ block.caption_entities = entities
58
+ if commit:
59
+ block.save()
60
+ return block
File without changes
File without changes