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.
- django_tgcms-0.1.0.dist-info/METADATA +203 -0
- django_tgcms-0.1.0.dist-info/RECORD +31 -0
- django_tgcms-0.1.0.dist-info/WHEEL +4 -0
- django_tgcms-0.1.0.dist-info/licenses/LICENSE +21 -0
- tgcms/__init__.py +6 -0
- tgcms/admin.py +60 -0
- tgcms/apps.py +7 -0
- tgcms/conf.py +21 -0
- tgcms/formatting/__init__.py +16 -0
- tgcms/formatting/entities.py +31 -0
- tgcms/formatting/html.py +181 -0
- tgcms/forms.py +60 -0
- tgcms/management/__init__.py +0 -0
- tgcms/management/commands/__init__.py +0 -0
- tgcms/management/commands/send_post.py +127 -0
- tgcms/migrations/0001_initial.py +30 -0
- tgcms/migrations/0002_remove_post_entities_remove_post_text_and_more.py +44 -0
- tgcms/migrations/0003_block_file_url_block_telegram_file_id.py +23 -0
- tgcms/migrations/0004_mediaasset_remove_block_file_remove_block_file_url_and_more.py +45 -0
- tgcms/migrations/__init__.py +0 -0
- tgcms/models.py +123 -0
- tgcms/static/tgcms/block_inline.js +214 -0
- tgcms/static/tgcms/editor.css +132 -0
- tgcms/static/tgcms/editor.js +81 -0
- tgcms/templates/tgcms/base.html +17 -0
- tgcms/templates/tgcms/post_form.html +52 -0
- tgcms/templates/tgcms/post_list.html +18 -0
- tgcms/templates/tgcms/post_published.html +12 -0
- tgcms/urls.py +13 -0
- tgcms/views.py +45 -0
- tgcms/widgets.py +46 -0
|
@@ -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,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
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
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
|
tgcms/formatting/html.py
ADDED
|
@@ -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
|