##// END OF EJS Templates
Added sticker pack functionality
neko259 -
r1951:1024ce38 default
parent child Browse files
Show More
@@ -0,0 +1,54 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.11 on 2017-10-25 08:48
3 from __future__ import unicode_literals
4
5 from django.db import migrations, models
6 import django.db.models.deletion
7
8
9 class Migration(migrations.Migration):
10
11 dependencies = [
12 ('boards', '0065_attachmentsticker'),
13 ]
14
15 def stickers_to_pack(apps, schema_editor):
16 AttachmentSticker = apps.get_model('boards', 'AttachmentSticker')
17 StickerPack = apps.get_model('boards', 'StickerPack')
18
19 stickers = AttachmentSticker.objects.all()
20 if len(stickers) > 0:
21 stickerpack = StickerPack.objects.create(name='general')
22 for sticker in stickers:
23 sticker.stickerpack = stickerpack
24 if '/' in sticker.name:
25 sticker.name = sticker.name.split('/')[1]
26 sticker.save()
27
28 operations = [
29 migrations.CreateModel(
30 name='StickerPack',
31 fields=[
32 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33 ('name', models.TextField(unique=True)),
34 ('tripcode', models.TextField(blank=True)),
35 ],
36 ),
37 migrations.AddField(
38 model_name='attachmentsticker',
39 name='stickerpack',
40 field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boards.StickerPack', null=True),
41 preserve_default=False,
42 ),
43 migrations.AddField(
44 model_name='thread',
45 name='stickerpack',
46 field=models.BooleanField(default=False),
47 ),
48 migrations.RunPython(stickers_to_pack),
49 migrations.AlterField(
50 model_name='attachmentsticker',
51 name='stickerpack',
52 field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boards.StickerPack', null=False),
53 ),
54 ]
@@ -1,1 +1,4 b''
1 import re
2
1 3 FILE_DIRECTORY = 'files/'
4 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
@@ -1,32 +1,33 b''
1 1 from boards.abstracts.settingsmanager import SessionSettingsManager
2 2 from boards.models import Attachment
3 3
4 4
5 5 class StickerFactory:
6 6 def get_image(self, alias):
7 7 pass
8 8
9 9
10 10 class SessionStickerFactory(StickerFactory):
11 11 def __init__(self, session):
12 12 self.session = session
13 13
14 14 def get_image(self, alias):
15 15 settings_manager = SessionSettingsManager(self.session)
16 16 return settings_manager.get_attachment_by_alias(alias)
17 17
18 18
19 19 class ModelStickerFactory(StickerFactory):
20 20 def get_image(self, alias):
21 if alias.count('/') == 1:
21 22 return Attachment.objects.get_by_alias(alias)
22 23
23 24
24 25 def get_attachment_by_alias(alias, session):
25 26 """Gets attachment from a source (local or server/global) using an alias."""
26 27
27 28 factories = [SessionStickerFactory(session), ModelStickerFactory()]
28 29 for factory in factories:
29 30 image = factory.get_image(alias)
30 31
31 32 if image is not None:
32 33 return image
@@ -1,175 +1,181 b''
1 1 from boards.abstracts.sticker_factory import StickerFactory
2 from boards.models.attachment import FILE_TYPES_IMAGE, AttachmentSticker
2 from boards.models.attachment import FILE_TYPES_IMAGE, AttachmentSticker, \
3 StickerPack
3 4 from django.contrib import admin
4 5 from django.utils.translation import ugettext_lazy as _
5 6 from django.core.urlresolvers import reverse
6 7 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, \
7 8 KeyPair, GlobalId, TagAlias
8 9
9 10
10 11 @admin.register(Post)
11 12 class PostAdmin(admin.ModelAdmin):
12 13
13 14 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
14 15 'foreign', 'tags')
15 16 list_filter = ('pub_time',)
16 17 search_fields = ('id', 'title', 'text', 'poster_ip')
17 18 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
18 19 readonly_fields = ('poster_ip', 'thread', 'linked_images',
19 20 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
20 21 'foreign', 'tags')
21 22
22 23 def ban_poster(self, request, queryset):
23 24 bans = 0
24 25 for post in queryset:
25 26 poster_ip = post.poster_ip
26 27 ban, created = Ban.objects.get_or_create(ip=poster_ip)
27 28 if created:
28 29 bans += 1
29 30 self.message_user(request, _('{} posters were banned').format(bans))
30 31
31 32 def ban_latter_with_delete(self, request, queryset):
32 33 bans = 0
33 34 hidden = 0
34 35 for post in queryset:
35 36 poster_ip = post.poster_ip
36 37 ban, created = Ban.objects.get_or_create(ip=poster_ip)
37 38 if created:
38 39 bans += 1
39 40 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
40 41 hidden += posts.count()
41 42 posts.delete()
42 43 self.message_user(request, _('{} posters were banned, {} messages were removed.').format(bans, hidden))
43 44 ban_latter_with_delete.short_description = _('Ban user and delete posts starting from this one and later')
44 45
45 46 def linked_images(self, obj: Post):
46 47 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
47 48 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
48 49 reverse('admin:%s_%s_change' % (image._meta.app_label,
49 50 image._meta.model_name),
50 51 args=[image.id]), image.get_thumb_url()) for image in images]
51 52 return ', '.join(image_urls)
52 53 linked_images.allow_tags = True
53 54
54 55 def linked_global_id(self, obj: Post):
55 56 global_id = obj.global_id
56 57 if global_id is not None:
57 58 return '<a href="{}">{}</a>'.format(
58 59 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
59 60 global_id._meta.model_name),
60 61 args=[global_id.id]), str(global_id))
61 62 linked_global_id.allow_tags = True
62 63
63 64 def tags(self, obj: Post):
64 65 return ', '.join([tag.get_name() for tag in obj.get_tags()])
65 66
66 67 def save_model(self, request, obj, form, change):
67 68 obj.save()
68 69 obj.clear_cache()
69 70
70 71 def foreign(self, obj: Post):
71 72 return obj is not None and obj.global_id is not None and\
72 73 not obj.global_id.is_local()
73 74
74 75 actions = ['ban_poster', 'ban_latter_with_delete']
75 76
76 77
77 78 @admin.register(Tag)
78 79 class TagAdmin(admin.ModelAdmin):
79 80 def thread_count(self, obj: Tag) -> int:
80 81 return obj.get_thread_count()
81 82
82 83 def display_children(self, obj: Tag):
83 84 return ', '.join([str(child) for child in obj.get_children().all()])
84 85
85 86 def name(self, obj: Tag):
86 87 return obj.get_name()
87 88
88 89 def save_model(self, request, obj, form, change):
89 90 super().save_model(request, obj, form, change)
90 91 for thread in obj.get_threads().all():
91 92 thread.refresh_tags()
92 93
93 94 list_display = ('name', 'thread_count', 'display_children')
94 95 search_fields = ('id',)
95 96 readonly_fields = ('name',)
96 97
97 98
98 99 @admin.register(TagAlias)
99 100 class TagAliasAdmin(admin.ModelAdmin):
100 101 list_display = ('locale', 'name', 'parent')
101 102 list_filter = ('locale',)
102 103 search_fields = ('name',)
103 104
104 105
105 106 @admin.register(Thread)
106 107 class ThreadAdmin(admin.ModelAdmin):
107 108
108 109 def title(self, obj: Thread) -> str:
109 110 return obj.get_opening_post().get_title()
110 111
111 112 def reply_count(self, obj: Thread) -> int:
112 113 return obj.get_reply_count()
113 114
114 115 def ip(self, obj: Thread):
115 116 return obj.get_opening_post().poster_ip
116 117
117 118 def display_tags(self, obj: Thread):
118 119 return ', '.join([str(tag) for tag in obj.get_tags().all()])
119 120
120 121 def op(self, obj: Thread):
121 122 return obj.get_opening_post_id()
122 123
123 124 # Save parent tags when editing tags
124 125 def save_related(self, request, form, formsets, change):
125 126 super().save_related(request, form, formsets, change)
126 127 form.instance.refresh_tags()
127 128
128 129 def save_model(self, request, obj, form, change):
129 130 op = obj.get_opening_post()
130 131 obj.save()
131 132 op.clear_cache()
132 133
133 134 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
134 135 'display_tags')
135 136 list_filter = ('bump_time', 'status')
136 137 search_fields = ('id', 'title')
137 138 filter_horizontal = ('tags',)
138 139
139 140
140 141 @admin.register(KeyPair)
141 142 class KeyPairAdmin(admin.ModelAdmin):
142 143 list_display = ('public_key', 'primary')
143 144 list_filter = ('primary',)
144 145 search_fields = ('public_key',)
145 146
146 147
147 148 @admin.register(Ban)
148 149 class BanAdmin(admin.ModelAdmin):
149 150 list_display = ('ip', 'can_read')
150 151 list_filter = ('can_read',)
151 152 search_fields = ('ip',)
152 153
153 154
154 155 @admin.register(Banner)
155 156 class BannerAdmin(admin.ModelAdmin):
156 157 list_display = ('title', 'text')
157 158
158 159
159 160 @admin.register(Attachment)
160 161 class AttachmentAdmin(admin.ModelAdmin):
161 162 list_display = ('__str__', 'mimetype', 'file', 'url')
162 163
163 164
164 165 @admin.register(AttachmentSticker)
165 166 class AttachmentStickerAdmin(admin.ModelAdmin):
166 167 search_fields = ('name',)
167 168
168 169
170 @admin.register(StickerPack)
171 class StickerPackAdmin(admin.ModelAdmin):
172 search_fields = ('name',)
173
174
169 175 @admin.register(GlobalId)
170 176 class GlobalIdAdmin(admin.ModelAdmin):
171 177 def is_linked(self, obj):
172 178 return Post.objects.filter(global_id=obj).exists()
173 179
174 180 list_display = ('__str__', 'is_linked',)
175 181 readonly_fields = ('content',)
@@ -1,559 +1,590 b''
1 1 import hashlib
2 2 import logging
3 3 import re
4 4 import time
5 import traceback
6 5
7 6 import pytz
8 7
9 8 from PIL import Image
10 9
11 10 from django import forms
12 11 from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
13 12 from django.forms.utils import ErrorList
14 13 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
15 14 from django.core.files.images import get_image_dimensions
16 15 from django.core.cache import cache
17 16
18 17 import boards.settings as board_settings
19 18 import neboard
20 19 from boards import utils
20 from boards.abstracts.constants import REGEX_TAGS
21 21 from boards.abstracts.sticker_factory import get_attachment_by_alias
22 22 from boards.abstracts.settingsmanager import get_settings_manager
23 23 from boards.forms.fields import UrlFileField
24 24 from boards.mdx_neboard import formatters
25 25 from boards.models import Attachment
26 26 from boards.models import Tag
27 from boards.models.attachment import StickerPack
27 28 from boards.models.attachment.downloaders import download, REGEX_MAGNET
28 29 from boards.models.post import TITLE_MAX_LENGTH
29 30 from boards.utils import validate_file_size, get_file_mimetype, \
30 31 FILE_EXTENSION_DELIMITER
31 32 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
32 33 from neboard import settings
33 34
34 35 SECTION_FORMS = 'Forms'
35 36
36 37 POW_HASH_LENGTH = 16
37 38 POW_LIFE_MINUTES = 5
38 39
39 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
40 40 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
41 41 REGEX_URL = re.compile(r'^(http|https|ftp):\/\/', re.UNICODE)
42 42
43 43 VETERAN_POSTING_DELAY = 5
44 44
45 45 ATTRIBUTE_PLACEHOLDER = 'placeholder'
46 46 ATTRIBUTE_ROWS = 'rows'
47 47
48 48 LAST_POST_TIME = 'last_post_time'
49 49 LAST_LOGIN_TIME = 'last_login_time'
50 50 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
51 51 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
52 52
53 53 LABEL_TITLE = _('Title')
54 54 LABEL_TEXT = _('Text')
55 55 LABEL_TAG = _('Tag')
56 56 LABEL_SEARCH = _('Search')
57 57 LABEL_FILE = _('File')
58 58 LABEL_DUPLICATES = _('Check for duplicates')
59 59 LABEL_URL = _('Do not download URLs')
60 60
61 61 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
62 62 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
63 63 ERROR_MANY_FILES = 'You can post no more than %(files)d file.'
64 64 ERROR_MANY_FILES_PLURAL = 'You can post no more than %(files)d files.'
65 65 ERROR_DUPLICATES = 'Some files are already present on the board.'
66 66
67 67 TAG_MAX_LENGTH = 20
68 68
69 69 TEXTAREA_ROWS = 4
70 70
71 71 TRIPCODE_DELIM = '##'
72 72
73 73 # TODO Maybe this may be converted into the database table?
74 74 MIMETYPE_EXTENSIONS = {
75 75 'image/jpeg': 'jpeg',
76 76 'image/png': 'png',
77 77 'image/gif': 'gif',
78 78 'video/webm': 'webm',
79 79 'application/pdf': 'pdf',
80 80 'x-diff': 'diff',
81 81 'image/svg+xml': 'svg',
82 82 'application/x-shockwave-flash': 'swf',
83 83 'image/x-ms-bmp': 'bmp',
84 84 'image/bmp': 'bmp',
85 85 }
86 86
87 87 DOWN_MODE_DOWNLOAD = 'DOWNLOAD'
88 88 DOWN_MODE_URL = 'URL'
89 89 DOWN_MODE_TRY = 'TRY'
90 90
91 91
92 92 logger = logging.getLogger('boards.forms')
93 93
94 94
95 95 def get_timezones():
96 96 timezones = []
97 97 for tz in pytz.common_timezones:
98 98 timezones.append((tz, tz),)
99 99 return timezones
100 100
101 101
102 102 class FormatPanel(forms.Textarea):
103 103 """
104 104 Panel for text formatting. Consists of buttons to add different tags to the
105 105 form text area.
106 106 """
107 107
108 108 def render(self, name, value, attrs=None):
109 109 output = '<div id="mark-panel">'
110 110 for formatter in formatters:
111 111 output += '<span class="mark_btn"' + \
112 112 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
113 113 '\', \'' + formatter.format_right + '\')">' + \
114 114 formatter.preview_left + formatter.name + \
115 115 formatter.preview_right + '</span>'
116 116
117 117 output += '</div>'
118 118 output += super(FormatPanel, self).render(name, value, attrs=attrs)
119 119
120 120 return output
121 121
122 122
123 123 class PlainErrorList(ErrorList):
124 124 def __unicode__(self):
125 125 return self.as_text()
126 126
127 127 def as_text(self):
128 128 return ''.join(['(!) %s ' % e for e in self])
129 129
130 130
131 131 class NeboardForm(forms.Form):
132 132 """
133 133 Form with neboard-specific formatting.
134 134 """
135 135 required_css_class = 'required-field'
136 136
137 137 def as_div(self):
138 138 """
139 139 Returns this form rendered as HTML <as_div>s.
140 140 """
141 141
142 142 return self._html_output(
143 143 # TODO Do not show hidden rows in the list here
144 144 normal_row='<div class="form-row">'
145 145 '<div class="form-label">'
146 146 '%(label)s'
147 147 '</div>'
148 148 '<div class="form-input">'
149 149 '%(field)s'
150 150 '</div>'
151 151 '</div>'
152 152 '<div class="form-row">'
153 153 '%(help_text)s'
154 154 '</div>',
155 155 error_row='<div class="form-row">'
156 156 '<div class="form-label"></div>'
157 157 '<div class="form-errors">%s</div>'
158 158 '</div>',
159 159 row_ender='</div>',
160 160 help_text_html='%s',
161 161 errors_on_separate_row=True)
162 162
163 163 def as_json_errors(self):
164 164 errors = []
165 165
166 166 for name, field in list(self.fields.items()):
167 167 if self[name].errors:
168 168 errors.append({
169 169 'field': name,
170 170 'errors': self[name].errors.as_text(),
171 171 })
172 172
173 173 return errors
174 174
175 175
176 176 class PostForm(NeboardForm):
177 177
178 178 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
179 179 label=LABEL_TITLE,
180 180 widget=forms.TextInput(
181 181 attrs={ATTRIBUTE_PLACEHOLDER: 'Title{}tripcode'.format(TRIPCODE_DELIM)}))
182 182 text = forms.CharField(
183 183 widget=FormatPanel(attrs={
184 184 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
185 185 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
186 186 }),
187 187 required=False, label=LABEL_TEXT)
188 188 download_mode = forms.ChoiceField(
189 189 choices=(
190 190 (DOWN_MODE_TRY, _('Download or add URL')),
191 191 (DOWN_MODE_DOWNLOAD, _('Download or fail')),
192 192 (DOWN_MODE_URL, _('Insert as URLs')),
193 193 ),
194 194 initial=DOWN_MODE_TRY,
195 195 label=_('URL download mode'))
196 196 file = UrlFileField(required=False, label=LABEL_FILE)
197 197
198 198 # This field is for spam prevention only
199 199 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
200 200 widget=forms.TextInput(attrs={
201 201 'class': 'form-email'}))
202 202 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
203 203 check_duplicates = forms.BooleanField(required=False, label=LABEL_DUPLICATES)
204 204
205 205 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
206 206 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
207 207 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
208 208
209 209 session = None
210 210 need_to_ban = False
211 211
212 212 def clean_title(self):
213 213 title = self.cleaned_data['title']
214 214 if title:
215 215 if len(title) > TITLE_MAX_LENGTH:
216 216 raise forms.ValidationError(_('Title must have less than %s '
217 217 'characters') %
218 218 str(TITLE_MAX_LENGTH))
219 219 return title
220 220
221 221 def clean_text(self):
222 222 text = self.cleaned_data['text'].strip()
223 223 if text:
224 224 max_length = board_settings.get_int(SECTION_FORMS, 'MaxTextLength')
225 225 if len(text) > max_length:
226 226 raise forms.ValidationError(_('Text must have less than %s '
227 227 'characters') % str(max_length))
228 228 return text
229 229
230 230 def clean_file(self):
231 231 return self._clean_files(self.cleaned_data['file'])
232 232
233 233 def clean(self):
234 234 cleaned_data = super(PostForm, self).clean()
235 235
236 236 if cleaned_data['email']:
237 237 if board_settings.get_bool(SECTION_FORMS, 'Autoban'):
238 238 self.need_to_ban = True
239 239 raise forms.ValidationError('A human cannot enter a hidden field')
240 240
241 241 if not self.errors:
242 242 self._clean_text_file()
243 243
244 244 limit_speed = board_settings.get_bool(SECTION_FORMS, 'LimitPostingSpeed')
245 245 limit_first = board_settings.get_bool(SECTION_FORMS, 'LimitFirstPosting')
246 246
247 247 settings_manager = get_settings_manager(self)
248 248 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
249 249 pow_difficulty = board_settings.get_int(SECTION_FORMS, 'PowDifficulty')
250 250 if pow_difficulty > 0:
251 251 # PoW-based
252 252 if cleaned_data['timestamp'] \
253 253 and cleaned_data['iteration'] and cleaned_data['guess'] \
254 254 and not settings_manager.get_setting('confirmed_user'):
255 255 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
256 256 else:
257 257 # Time-based
258 258 self._validate_posting_speed()
259 259 settings_manager.set_setting('confirmed_user', True)
260 260 if self.cleaned_data['check_duplicates']:
261 261 self._check_file_duplicates(self.get_files())
262 262
263 263 return cleaned_data
264 264
265 265 def get_files(self):
266 266 """
267 267 Gets file from form or URL.
268 268 """
269 269
270 270 files = []
271 271 for file in self.cleaned_data['file']:
272 272 if isinstance(file, UploadedFile):
273 273 files.append(file)
274 274
275 275 return files
276 276
277 277 def get_file_urls(self):
278 278 files = []
279 279 for file in self.cleaned_data['file']:
280 280 if type(file) == str:
281 281 files.append(file)
282 282
283 283 return files
284 284
285 285 def get_tripcode(self):
286 286 title = self.cleaned_data['title']
287 287 if title is not None and TRIPCODE_DELIM in title:
288 288 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
289 289 tripcode = hashlib.md5(code.encode()).hexdigest()
290 290 else:
291 291 tripcode = ''
292 292 return tripcode
293 293
294 294 def get_title(self):
295 295 title = self.cleaned_data['title']
296 296 if title is not None and TRIPCODE_DELIM in title:
297 297 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
298 298 else:
299 299 return title
300 300
301 301 def get_images(self):
302 302 return self.cleaned_data.get('stickers', [])
303 303
304 304 def is_subscribe(self):
305 305 return self.cleaned_data['subscribe']
306 306
307 307 def _update_file_extension(self, file):
308 308 if file:
309 309 mimetype = get_file_mimetype(file)
310 310 extension = MIMETYPE_EXTENSIONS.get(mimetype)
311 311 if extension:
312 312 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
313 313 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
314 314
315 315 file.name = new_filename
316 316 else:
317 317 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
318 318
319 319 def _clean_files(self, inputs):
320 320 files = []
321 321
322 322 max_file_count = board_settings.get_int(SECTION_FORMS, 'MaxFileCount')
323 323 if len(inputs) > max_file_count:
324 324 raise forms.ValidationError(
325 325 ungettext_lazy(ERROR_MANY_FILES, ERROR_MANY_FILES,
326 326 max_file_count) % {'files': max_file_count})
327 327 for file_input in inputs:
328 328 if isinstance(file_input, UploadedFile):
329 329 files.append(self._clean_file_file(file_input))
330 330 else:
331 331 files.append(self._clean_file_url(file_input))
332 332
333 333 for file in files:
334 334 self._validate_image_dimensions(file)
335 335
336 336 return files
337 337
338 338 def _validate_image_dimensions(self, file):
339 339 if isinstance(file, UploadedFile):
340 340 mimetype = get_file_mimetype(file)
341 341 if mimetype.split('/')[-1] in FILE_TYPES_IMAGE:
342 342 Image.warnings.simplefilter('error', Image.DecompressionBombWarning)
343 343 try:
344 344 print(get_image_dimensions(file))
345 345 except Exception:
346 346 raise forms.ValidationError('Possible decompression bomb or large image.')
347 347
348 348 def _clean_file_file(self, file):
349 349 validate_file_size(file.size)
350 350 self._update_file_extension(file)
351 351
352 352 return file
353 353
354 354 def _clean_file_url(self, url):
355 355 file = None
356 356
357 357 if url:
358 358 mode = self.cleaned_data['download_mode']
359 359 if mode == DOWN_MODE_URL:
360 360 return url
361 361
362 362 try:
363 363 image = get_attachment_by_alias(url, self.session)
364 364 if image is not None:
365 365 if 'stickers' not in self.cleaned_data:
366 366 self.cleaned_data['stickers'] = []
367 367 self.cleaned_data['stickers'].append(image)
368 368 return
369 369
370 370 if file is None:
371 371 file = self._get_file_from_url(url)
372 372 if not file:
373 373 raise forms.ValidationError(_('Invalid URL'))
374 374 else:
375 375 validate_file_size(file.size)
376 376 self._update_file_extension(file)
377 377 except forms.ValidationError as e:
378 378 # Assume we will get the plain URL instead of a file and save it
379 379 if mode == DOWN_MODE_TRY and (REGEX_URL.match(url) or REGEX_MAGNET.match(url)):
380 380 logger.info('Error in forms: {}'.format(e))
381 381 return url
382 382 else:
383 383 raise e
384 384
385 385 return file
386 386
387 387 def _clean_text_file(self):
388 388 text = self.cleaned_data.get('text')
389 389 file = self.get_files()
390 390 file_url = self.get_file_urls()
391 391 images = self.get_images()
392 392
393 393 if (not text) and (not file) and (not file_url) and len(images) == 0:
394 394 error_message = _('Either text or file must be entered.')
395 395 self._add_general_error(error_message)
396 396
397 397 def _get_cache_key(self, key):
398 398 return '{}_{}'.format(self.session.session_key, key)
399 399
400 400 def _set_session_cache(self, key, value):
401 401 cache.set(self._get_cache_key(key), value)
402 402
403 403 def _get_session_cache(self, key):
404 404 return cache.get(self._get_cache_key(key))
405 405
406 406 def _get_last_post_time(self):
407 407 last = self._get_session_cache(LAST_POST_TIME)
408 408 if last is None:
409 409 last = self.session.get(LAST_POST_TIME)
410 410 return last
411 411
412 412 def _validate_posting_speed(self):
413 413 can_post = True
414 414
415 415 posting_delay = board_settings.get_int(SECTION_FORMS, 'PostingDelay')
416 416
417 417 if board_settings.get_bool(SECTION_FORMS, 'LimitPostingSpeed'):
418 418 now = time.time()
419 419
420 420 current_delay = 0
421 421
422 422 if LAST_POST_TIME not in self.session:
423 423 self.session[LAST_POST_TIME] = now
424 424
425 425 need_delay = True
426 426 else:
427 427 last_post_time = self._get_last_post_time()
428 428 current_delay = int(now - last_post_time)
429 429
430 430 need_delay = current_delay < posting_delay
431 431
432 432 self._set_session_cache(LAST_POST_TIME, now)
433 433
434 434 if need_delay:
435 435 delay = posting_delay - current_delay
436 436 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
437 437 delay) % {'delay': delay}
438 438 self._add_general_error(error_message)
439 439
440 440 can_post = False
441 441
442 442 if can_post:
443 443 self.session[LAST_POST_TIME] = now
444 444 else:
445 445 # Reset the time since posting failed
446 446 self._set_session_cache(LAST_POST_TIME, self.session[LAST_POST_TIME])
447 447
448 448 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
449 449 """
450 450 Gets an file file from URL.
451 451 """
452 452
453 453 try:
454 454 return download(url)
455 455 except forms.ValidationError as e:
456 456 raise e
457 457 except Exception as e:
458 458 raise forms.ValidationError(e)
459 459
460 460 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
461 461 payload = timestamp + message.replace('\r\n', '\n')
462 462 difficulty = board_settings.get_int(SECTION_FORMS, 'PowDifficulty')
463 463 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
464 464 if len(target) < POW_HASH_LENGTH:
465 465 target = '0' * (POW_HASH_LENGTH - len(target)) + target
466 466
467 467 computed_guess = hashlib.sha256((payload + iteration).encode())\
468 468 .hexdigest()[0:POW_HASH_LENGTH]
469 469 if guess != computed_guess or guess > target:
470 470 self._add_general_error(_('Invalid PoW.'))
471 471
472 472 def _check_file_duplicates(self, files):
473 473 for file in files:
474 474 file_hash = utils.get_file_hash(file)
475 475 if Attachment.objects.get_existing_duplicate(file_hash, file):
476 476 self._add_general_error(_(ERROR_DUPLICATES))
477 477
478 478 def _add_general_error(self, message):
479 479 self.add_error('text', forms.ValidationError(message))
480 480
481 481
482 482 class ThreadForm(PostForm):
483 483
484 484 tags = forms.CharField(
485 485 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
486 486 max_length=100, label=_('Tags'), required=True)
487 487 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
488 stickerpack = forms.BooleanField(label=_('Sticker Pack'), required=False)
488 489
489 490 def clean_tags(self):
490 491 tags = self.cleaned_data['tags'].strip()
491 492
492 493 if not tags or not REGEX_TAGS.match(tags):
493 494 raise forms.ValidationError(
494 495 _('Inappropriate characters in tags.'))
495 496
496 497 default_tag_name = board_settings.get(SECTION_FORMS, 'DefaultTag')\
497 498 .strip().lower()
498 499
499 500 required_tag_exists = False
500 501 tag_set = set()
501 502 for tag_string in tags.split():
502 503 tag_name = tag_string.strip().lower()
503 504 if tag_name == default_tag_name:
504 505 required_tag_exists = True
505 506 tag, created = Tag.objects.get_or_create_with_alias(
506 507 name=tag_name, required=True)
507 508 else:
508 509 tag, created = Tag.objects.get_or_create_with_alias(name=tag_name)
509 510 tag_set.add(tag)
510 511
511 512 # If this is a new tag, don't check for its parents because nobody
512 513 # added them yet
513 514 if not created:
514 515 tag_set |= set(tag.get_all_parents())
515 516
516 517 for tag in tag_set:
517 518 if tag.required:
518 519 required_tag_exists = True
519 520 break
520 521
521 522 # Use default tag if no section exists
522 523 if not required_tag_exists:
523 524 default_tag, created = Tag.objects.get_or_create_with_alias(
524 525 name=default_tag_name, required=True)
525 526 tag_set.add(default_tag)
526 527
527 528 return tag_set
528 529
529 530 def clean(self):
530 531 cleaned_data = super(ThreadForm, self).clean()
531 532
532 533 return cleaned_data
533 534
534 535 def is_monochrome(self):
535 536 return self.cleaned_data['monochrome']
536 537
538 def clean_stickerpack(self):
539 stickerpack = self.cleaned_data['stickerpack']
540 if stickerpack:
541 tripcode = self.get_tripcode()
542 if not tripcode:
543 raise forms.ValidationError(_(
544 'Tripcode should be specified to own a stickerpack.'))
545 title = self.get_title()
546 if not title:
547 raise forms.ValidationError(_(
548 'Title should be specified as a stickerpack name.'))
549 if not REGEX_TAGS.match(title):
550 raise forms.ValidationError(_('Inappropriate sticker pack name.'))
551
552 existing_pack = StickerPack.objects.filter(name=title).first()
553 if existing_pack:
554 if existing_pack.tripcode != tripcode:
555 raise forms.ValidationError(_(
556 'A sticker pack with this name already exists and is'
557 ' owned by another tripcode.'))
558 if not existing_pack.tripcode:
559 raise forms.ValidationError(_(
560 'This sticker pack can only be updated by an '
561 'administrator.'))
562
563 return stickerpack
564
565 def is_stickerpack(self):
566 return self.cleaned_data['stickerpack']
567
537 568
538 569 class SettingsForm(NeboardForm):
539 570
540 571 theme = forms.ChoiceField(
541 572 choices=board_settings.get_list_dict('View', 'Themes'),
542 573 label=_('Theme'))
543 574 image_viewer = forms.ChoiceField(
544 575 choices=board_settings.get_list_dict('View', 'ImageViewers'),
545 576 label=_('Image view mode'))
546 577 username = forms.CharField(label=_('User name'), required=False)
547 578 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
548 579
549 580 def clean_username(self):
550 581 username = self.cleaned_data['username']
551 582
552 583 if username and not REGEX_USERNAMES.match(username):
553 584 raise forms.ValidationError(_('Inappropriate characters.'))
554 585
555 586 return username
556 587
557 588
558 589 class SearchForm(NeboardForm):
559 590 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
1 NO CONTENT: modified file, binary diff hidden
@@ -1,621 +1,642 b''
1 1 # SOME DESCRIPTIVE TITLE.
2 2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 3 # This file is distributed under the same license as the PACKAGE package.
4 4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 5 #
6 6 msgid ""
7 7 msgstr ""
8 8 "Project-Id-Version: PACKAGE VERSION\n"
9 9 "Report-Msgid-Bugs-To: \n"
10 10 "POT-Creation-Date: 2015-10-09 23:21+0300\n"
11 11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 13 "Language-Team: LANGUAGE <LL@li.org>\n"
14 14 "Language: ru\n"
15 15 "MIME-Version: 1.0\n"
16 16 "Content-Type: text/plain; charset=UTF-8\n"
17 17 "Content-Transfer-Encoding: 8bit\n"
18 18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20 20
21 21 #: admin.py:22
22 22 msgid "{} posters were banned"
23 23 msgstr ""
24 24
25 25 #: authors.py:9
26 26 msgid "author"
27 27 msgstr "Π°Π²Ρ‚ΠΎΡ€"
28 28
29 29 #: authors.py:10
30 30 msgid "developer"
31 31 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ"
32 32
33 33 #: authors.py:11
34 34 msgid "javascript developer"
35 35 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ javascript"
36 36
37 37 #: authors.py:12
38 38 msgid "designer"
39 39 msgstr "Π΄ΠΈΠ·Π°ΠΉΠ½Π΅Ρ€"
40 40
41 41 #: forms.py:30
42 42 msgid "Type message here. Use formatting panel for more advanced usage."
43 43 msgstr ""
44 44 "Π’Π²ΠΎΠ΄ΠΈΡ‚Π΅ сообщСниС сюда. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ панСль для Π±ΠΎΠ»Π΅Π΅ слоТного форматирования."
45 45
46 46 #: forms.py:31
47 47 msgid "music images i_dont_like_tags"
48 48 msgstr "ΠΌΡƒΠ·Ρ‹ΠΊΠ° ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Ρ‚Π΅Π³ΠΈ_Π½Π΅_Π½ΡƒΠΆΠ½Ρ‹"
49 49
50 50 #: forms.py:33
51 51 msgid "Title"
52 52 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ"
53 53
54 54 #: forms.py:34
55 55 msgid "Text"
56 56 msgstr "ВСкст"
57 57
58 58 #: forms.py:35
59 59 msgid "Tag"
60 60 msgstr "ΠœΠ΅Ρ‚ΠΊΠ°"
61 61
62 62 #: forms.py:36 templates/boards/base.html:40 templates/search/search.html:7
63 63 msgid "Search"
64 64 msgstr "Поиск"
65 65
66 66 #: forms.py:48
67 67 msgid "File 1"
68 68 msgstr "Π€Π°ΠΉΠ» 1"
69 69
70 70 #: forms.py:48
71 71 msgid "File 2"
72 72 msgstr "Π€Π°ΠΉΠ» 2"
73 73
74 74 #: forms.py:142
75 75 msgid "File URL"
76 76 msgstr "URL Ρ„Π°ΠΉΠ»Π°"
77 77
78 78 #: forms.py:148
79 79 msgid "e-mail"
80 80 msgstr ""
81 81
82 82 #: forms.py:151
83 83 msgid "Additional threads"
84 84 msgstr "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
85 85
86 86 #: forms.py:162
87 87 #, python-format
88 88 msgid "Title must have less than %s characters"
89 89 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ мСньшС %s символов"
90 90
91 91 #: forms.py:172
92 92 #, python-format
93 93 msgid "Text must have less than %s characters"
94 94 msgstr "ВСкст Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΡ€ΠΎΡ‡Π΅ %s символов"
95 95
96 96 #: forms.py:192
97 97 msgid "Invalid URL"
98 98 msgstr "НСвСрный URL"
99 99
100 100 #: forms.py:213
101 101 msgid "Invalid additional thread list"
102 102 msgstr "НСвСрный список Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
103 103
104 104 #: forms.py:258
105 105 msgid "Either text or file must be entered."
106 106 msgstr "ВСкст ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Π²Π²Π΅Π΄Π΅Π½Ρ‹."
107 107
108 108 #: forms.py:317 templates/boards/all_threads.html:153
109 109 #: templates/boards/rss/post.html:10 templates/boards/tags.html:6
110 110 msgid "Tags"
111 111 msgstr "ΠœΠ΅Ρ‚ΠΊΠΈ"
112 112
113 113 #: forms.py:324
114 114 msgid "Inappropriate characters in tags."
115 115 msgstr "НСдопустимыС символы Π² ΠΌΠ΅Ρ‚ΠΊΠ°Ρ…."
116 116
117 117 #: forms.py:344
118 118 msgid "Need at least one section."
119 119 msgstr "НуТСн хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·Π΄Π΅Π»."
120 120
121 121 #: forms.py:356
122 122 msgid "Theme"
123 123 msgstr "Π’Π΅ΠΌΠ°"
124 124
125 125 #: forms.py:357
126 126 msgid "Image view mode"
127 127 msgstr "Π Π΅ΠΆΠΈΠΌ просмотра ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
128 128
129 129 #: forms.py:358
130 130 msgid "User name"
131 131 msgstr "Имя ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ"
132 132
133 133 #: forms.py:359
134 134 msgid "Time zone"
135 135 msgstr "Часовой пояс"
136 136
137 137 #: forms.py:365
138 138 msgid "Inappropriate characters."
139 139 msgstr "НСдопустимыС символы."
140 140
141 141 #: templates/boards/404.html:6
142 142 msgid "Not found"
143 143 msgstr "НС найдСно"
144 144
145 145 #: templates/boards/404.html:12
146 146 msgid "This page does not exist"
147 147 msgstr "Π­Ρ‚ΠΎΠΉ страницы Π½Π΅ сущСствуСт"
148 148
149 149 #: templates/boards/all_threads.html:35
150 150 msgid "Details"
151 151 msgstr "ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½ΠΎΡΡ‚ΠΈ"
152 152
153 153 #: templates/boards/all_threads.html:69
154 154 msgid "Edit tag"
155 155 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΡƒ"
156 156
157 157 #: templates/boards/all_threads.html:76
158 158 #, python-format
159 159 msgid "%(count)s active thread"
160 160 msgid_plural "%(count)s active threads"
161 161 msgstr[0] "%(count)s активная Ρ‚Π΅ΠΌΠ°"
162 162 msgstr[1] "%(count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
163 163 msgstr[2] "%(count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
164 164
165 165 #: templates/boards/all_threads.html:76
166 166 #, python-format
167 167 msgid "%(count)s thread in bumplimit"
168 168 msgid_plural "%(count)s threads in bumplimit"
169 169 msgstr[0] "%(count)s Ρ‚Π΅ΠΌΠ° Π² Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π΅"
170 170 msgstr[1] "%(count)s Ρ‚Π΅ΠΌΡ‹ Π² Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π΅"
171 171 msgstr[2] "%(count)s Ρ‚Π΅ΠΌ Π² Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π΅"
172 172
173 173 #: templates/boards/all_threads.html:77
174 174 #, python-format
175 175 msgid "%(count)s archived thread"
176 176 msgid_plural "%(count)s archived thread"
177 177 msgstr[0] "%(count)s архивная Ρ‚Π΅ΠΌΠ°"
178 178 msgstr[1] "%(count)s Π°Ρ€Ρ…ΠΈΠ²Π½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
179 179 msgstr[2] "%(count)s Π°Ρ€Ρ…ΠΈΠ²Π½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
180 180
181 181 #: templates/boards/all_threads.html:78 templates/boards/post.html:102
182 182 #, python-format
183 183 #| msgid "%(count)s message"
184 184 #| msgid_plural "%(count)s messages"
185 185 msgid "%(count)s message"
186 186 msgid_plural "%(count)s messages"
187 187 msgstr[0] "%(count)s сообщСниС"
188 188 msgstr[1] "%(count)s сообщСния"
189 189 msgstr[2] "%(count)s сообщСний"
190 190
191 191 #: templates/boards/all_threads.html:95 templates/boards/feed.html:30
192 192 #: templates/boards/notifications.html:17 templates/search/search.html:26
193 193 msgid "Previous page"
194 194 msgstr "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ страница"
195 195
196 196 #: templates/boards/all_threads.html:109
197 197 #, python-format
198 198 msgid "Skipped %(count)s reply. Open thread to see all replies."
199 199 msgid_plural "Skipped %(count)s replies. Open thread to see all replies."
200 200 msgstr[0] "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ %(count)s ΠΎΡ‚Π²Π΅Ρ‚. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
201 201 msgstr[1] ""
202 202 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚Π°. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
203 203 msgstr[2] ""
204 204 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ². ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
205 205
206 206 #: templates/boards/all_threads.html:127 templates/boards/feed.html:40
207 207 #: templates/boards/notifications.html:27 templates/search/search.html:37
208 208 msgid "Next page"
209 209 msgstr "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ страница"
210 210
211 211 #: templates/boards/all_threads.html:132
212 212 msgid "No threads exist. Create the first one!"
213 213 msgstr "НСт Ρ‚Π΅ΠΌ. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΡƒΡŽ!"
214 214
215 215 #: templates/boards/all_threads.html:138
216 216 msgid "Create new thread"
217 217 msgstr "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ"
218 218
219 219 #: templates/boards/all_threads.html:143 templates/boards/preview.html:16
220 220 #: templates/boards/thread_normal.html:51
221 221 msgid "Post"
222 222 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ"
223 223
224 224 #: templates/boards/all_threads.html:144 templates/boards/preview.html:6
225 225 #: templates/boards/staticpages/help.html:21
226 226 #: templates/boards/thread_normal.html:52
227 227 msgid "Preview"
228 228 msgstr "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€"
229 229
230 230 #: templates/boards/all_threads.html:149
231 231 msgid "Tags must be delimited by spaces. Text or image is required."
232 232 msgstr ""
233 233 "ΠœΠ΅Ρ‚ΠΊΠΈ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Ρ€Π°Π·Π΄Π΅Π»Π΅Π½Ρ‹ ΠΏΡ€ΠΎΠ±Π΅Π»Π°ΠΌΠΈ. ВСкст ΠΈΠ»ΠΈ ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹."
234 234
235 235 #: templates/boards/all_threads.html:152 templates/boards/thread_normal.html:58
236 236 msgid "Text syntax"
237 237 msgstr "Бинтаксис тСкста"
238 238
239 239 #: templates/boards/all_threads.html:166 templates/boards/feed.html:53
240 240 msgid "Pages:"
241 241 msgstr "Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹: "
242 242
243 243 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
244 244 msgid "Authors"
245 245 msgstr "Авторы"
246 246
247 247 #: templates/boards/authors.html:26
248 248 msgid "Distributed under the"
249 249 msgstr "РаспространяСтся ΠΏΠΎΠ΄"
250 250
251 251 #: templates/boards/authors.html:28
252 252 msgid "license"
253 253 msgstr "Π»ΠΈΡ†Π΅Π½Π·ΠΈΠ΅ΠΉ"
254 254
255 255 #: templates/boards/authors.html:30
256 256 msgid "Repository"
257 257 msgstr "Π Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ"
258 258
259 259 #: templates/boards/base.html:14 templates/boards/base.html.py:41
260 260 msgid "Feed"
261 261 msgstr "Π›Π΅Π½Ρ‚Π°"
262 262
263 263 #: templates/boards/base.html:31
264 264 msgid "All threads"
265 265 msgstr "ВсС Ρ‚Π΅ΠΌΡ‹"
266 266
267 267 #: templates/boards/base.html:37
268 268 msgid "Add tags"
269 269 msgstr "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΠΈ"
270 270
271 271 #: templates/boards/base.html:39
272 272 msgid "Tag management"
273 273 msgstr "Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠ°ΠΌΠΈ"
274 274
275 275 #: templates/boards/base.html:39
276 276 msgid "tags"
277 277 msgstr "ΠΌΠ΅Ρ‚ΠΊΠΈ"
278 278
279 279 #: templates/boards/base.html:40
280 280 msgid "search"
281 281 msgstr "поиск"
282 282
283 283 #: templates/boards/base.html:41 templates/boards/feed.html:11
284 284 msgid "feed"
285 285 msgstr "Π»Π΅Π½Ρ‚Π°"
286 286
287 287 #: templates/boards/base.html:42 templates/boards/random.html:6
288 288 msgid "Random images"
289 289 msgstr "Π‘Π»ΡƒΡ‡Π°ΠΉΠ½Ρ‹Π΅ изобраТСния"
290 290
291 291 #: templates/boards/base.html:42
292 292 msgid "random"
293 293 msgstr "случайныС"
294 294
295 295 #: templates/boards/base.html:44
296 296 msgid "favorites"
297 297 msgstr "ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅"
298 298
299 299 #: templates/boards/base.html:48 templates/boards/base.html.py:49
300 300 #: templates/boards/notifications.html:8
301 301 msgid "Notifications"
302 302 msgstr "УвСдомлСния"
303 303
304 304 #: templates/boards/base.html:56 templates/boards/settings.html:8
305 305 msgid "Settings"
306 306 msgstr "Настройки"
307 307
308 308 #: templates/boards/base.html:59
309 309 msgid "Loading..."
310 310 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
311 311
312 312 #: templates/boards/base.html:71
313 313 msgid "Admin"
314 314 msgstr "АдминистрированиС"
315 315
316 316 #: templates/boards/base.html:73
317 317 #, python-format
318 318 msgid "Speed: %(ppd)s posts per day"
319 319 msgstr "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ: %(ppd)s сообщСний Π² дСнь"
320 320
321 321 #: templates/boards/base.html:75
322 322 msgid "Up"
323 323 msgstr "Π’Π²Π΅Ρ€Ρ…"
324 324
325 325 #: templates/boards/feed.html:45
326 326 msgid "No posts exist. Create the first one!"
327 327 msgstr "НСт сообщСний. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΠΎΠ΅!"
328 328
329 329 #: templates/boards/post.html:33
330 330 msgid "Open"
331 331 msgstr "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ"
332 332
333 333 #: templates/boards/post.html:35 templates/boards/post.html.py:46
334 334 msgid "Reply"
335 335 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ"
336 336
337 337 #: templates/boards/post.html:41
338 338 msgid " in "
339 339 msgstr " Π² "
340 340
341 341 #: templates/boards/post.html:51
342 342 msgid "Edit"
343 343 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ"
344 344
345 345 #: templates/boards/post.html:53
346 346 msgid "Edit thread"
347 347 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ"
348 348
349 349 #: templates/boards/post.html:91
350 350 msgid "Replies"
351 351 msgstr "ΠžΡ‚Π²Π΅Ρ‚Ρ‹"
352 352
353 353 #: templates/boards/post.html:103
354 354 #, python-format
355 355 msgid "%(count)s image"
356 356 msgid_plural "%(count)s images"
357 357 msgstr[0] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
358 358 msgstr[1] "%(count)s изобраТСния"
359 359 msgstr[2] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
360 360
361 361 #: templates/boards/rss/post.html:5
362 362 msgid "Post image"
363 363 msgstr "Π˜Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ сообщСния"
364 364
365 365 #: templates/boards/settings.html:15
366 366 msgid "You are moderator."
367 367 msgstr "Π’Ρ‹ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ‚ΠΎΡ€."
368 368
369 369 #: templates/boards/settings.html:19
370 370 msgid "Hidden tags:"
371 371 msgstr "Π‘ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
372 372
373 373 #: templates/boards/settings.html:25
374 374 msgid "No hidden tags."
375 375 msgstr "НСт скрытых ΠΌΠ΅Ρ‚ΠΎΠΊ."
376 376
377 377 #: templates/boards/settings.html:34
378 378 msgid "Save"
379 379 msgstr "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ"
380 380
381 381 #: templates/boards/staticpages/banned.html:6
382 382 msgid "Banned"
383 383 msgstr "Π—Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½"
384 384
385 385 #: templates/boards/staticpages/banned.html:11
386 386 msgid "Your IP address has been banned. Contact the administrator"
387 387 msgstr "Π’Π°Ρˆ IP адрСс Π±Ρ‹Π» Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½. Π‘Π²ΡΠΆΠΈΡ‚Π΅ΡΡŒ с администратором"
388 388
389 389 #: templates/boards/staticpages/help.html:6
390 390 #: templates/boards/staticpages/help.html:10
391 391 msgid "Syntax"
392 392 msgstr "Бинтаксис"
393 393
394 394 #: templates/boards/staticpages/help.html:11
395 395 msgid "Italic text"
396 396 msgstr "ΠšΡƒΡ€ΡΠΈΠ²Π½Ρ‹ΠΉ тСкст"
397 397
398 398 #: templates/boards/staticpages/help.html:12
399 399 msgid "Bold text"
400 400 msgstr "ΠŸΠΎΠ»ΡƒΠΆΠΈΡ€Π½Ρ‹ΠΉ тСкст"
401 401
402 402 #: templates/boards/staticpages/help.html:13
403 403 msgid "Spoiler"
404 404 msgstr "Π‘ΠΏΠΎΠΉΠ»Π΅Ρ€"
405 405
406 406 #: templates/boards/staticpages/help.html:14
407 407 msgid "Link to a post"
408 408 msgstr "Бсылка Π½Π° сообщСниС"
409 409
410 410 #: templates/boards/staticpages/help.html:15
411 411 msgid "Strikethrough text"
412 412 msgstr "Π—Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ тСкст"
413 413
414 414 #: templates/boards/staticpages/help.html:16
415 415 msgid "Comment"
416 416 msgstr "ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ"
417 417
418 418 #: templates/boards/staticpages/help.html:17
419 419 #: templates/boards/staticpages/help.html:18
420 420 msgid "Quote"
421 421 msgstr "Π¦ΠΈΡ‚Π°Ρ‚Π°"
422 422
423 423 #: templates/boards/staticpages/help.html:21
424 424 msgid "You can try pasting the text and previewing the result here:"
425 425 msgstr "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΠΎΠΏΡ€ΠΎΠ±ΠΎΠ²Π°Ρ‚ΡŒ Π²ΡΡ‚Π°Π²ΠΈΡ‚ΡŒ тСкст ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ здСсь:"
426 426
427 427 #: templates/boards/thread.html:14
428 428 msgid "Normal"
429 429 msgstr "ΠΠΎΡ€ΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ"
430 430
431 431 #: templates/boards/thread.html:15
432 432 msgid "Gallery"
433 433 msgstr "ГалСрСя"
434 434
435 435 #: templates/boards/thread.html:16
436 436 msgid "Tree"
437 437 msgstr "Π”Π΅Ρ€Π΅Π²ΠΎ"
438 438
439 439 #: templates/boards/thread.html:35
440 440 msgid "message"
441 441 msgid_plural "messages"
442 442 msgstr[0] "сообщСниС"
443 443 msgstr[1] "сообщСния"
444 444 msgstr[2] "сообщСний"
445 445
446 446 #: templates/boards/thread.html:38
447 447 msgid "image"
448 448 msgid_plural "images"
449 449 msgstr[0] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
450 450 msgstr[1] "изобраТСния"
451 451 msgstr[2] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
452 452
453 453 #: templates/boards/thread.html:40
454 454 msgid "Last update: "
455 455 msgstr "ПослСднСС обновлСниС: "
456 456
457 457 #: templates/boards/thread_gallery.html:36
458 458 msgid "No images."
459 459 msgstr "НСт ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ."
460 460
461 461 #: templates/boards/thread_normal.html:30
462 462 msgid "posts to bumplimit"
463 463 msgstr "сообщСний Π΄ΠΎ Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π°"
464 464
465 465 #: templates/boards/thread_normal.html:44
466 466 msgid "Reply to thread"
467 467 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ Π² Ρ‚Π΅ΠΌΡƒ"
468 468
469 469 #: templates/boards/thread_normal.html:44
470 470 msgid "to message "
471 471 msgstr "Π½Π° сообщСниС"
472 472
473 473 #: templates/boards/thread_normal.html:59
474 474 msgid "Reset form"
475 475 msgstr "Π‘Π±Ρ€ΠΎΡΠΈΡ‚ΡŒ Ρ„ΠΎΡ€ΠΌΡƒ"
476 476
477 477 #: templates/search/search.html:17
478 478 msgid "Ok"
479 479 msgstr "Ок"
480 480
481 481 #: utils.py:120
482 482 #, python-format
483 483 msgid "File must be less than %s but is %s."
484 484 msgstr "Π€Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΌΠ΅Π½Π΅Π΅ %s, Π½ΠΎ Π΅Π³ΠΎ Ρ€Π°Π·ΠΌΠ΅Ρ€ %s."
485 485
486 486 msgid "Please wait %(delay)d second before sending message"
487 487 msgid_plural "Please wait %(delay)d seconds before sending message"
488 488 msgstr[0] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунду ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
489 489 msgstr[1] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунды ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
490 490 msgstr[2] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунд ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
491 491
492 492 msgid "New threads"
493 493 msgstr "НовыС Ρ‚Π΅ΠΌΡ‹"
494 494
495 495 #, python-format
496 496 msgid "Max file size is %(size)s."
497 497 msgstr "ΠœΠ°ΠΊΡΠΈΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ Ρ€Π°Π·ΠΌΠ΅Ρ€ Ρ„Π°ΠΉΠ»Π° %(size)s."
498 498
499 499 msgid "Size of media:"
500 500 msgstr "Π Π°Π·ΠΌΠ΅Ρ€ ΠΌΠ΅Π΄ΠΈΠ°:"
501 501
502 502 msgid "Statistics"
503 503 msgstr "Бтатистика"
504 504
505 505 msgid "Invalid PoW."
506 506 msgstr "НСвСрный PoW."
507 507
508 508 msgid "Stale PoW."
509 509 msgstr "PoW устарСл."
510 510
511 511 msgid "Show"
512 512 msgstr "ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ"
513 513
514 514 msgid "Hide"
515 515 msgstr "Π‘ΠΊΡ€Ρ‹Π²Π°Ρ‚ΡŒ"
516 516
517 517 msgid "Add to favorites"
518 518 msgstr "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π² ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅"
519 519
520 520 msgid "Remove from favorites"
521 521 msgstr "Π£Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ"
522 522
523 523 msgid "Monochrome"
524 524 msgstr "ΠœΠΎΠ½ΠΎΡ…Ρ€ΠΎΠΌΠ½Ρ‹ΠΉ"
525 525
526 526 msgid "Subsections: "
527 527 msgstr "ΠŸΠΎΠ΄Ρ€Π°Π·Π΄Π΅Π»Ρ‹: "
528 528
529 529 msgid "Change file source"
530 530 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ источник Ρ„Π°ΠΉΠ»Π°"
531 531
532 532 msgid "interesting"
533 533 msgstr "интСрСсноС"
534 534
535 535 msgid "images"
536 536 msgstr "изобраТСния"
537 537
538 538 msgid "Delete post"
539 539 msgstr "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ пост"
540 540
541 541 msgid "Delete thread"
542 542 msgstr "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ"
543 543
544 544 msgid "Messages per day/week/month:"
545 545 msgstr "Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠΉ Π·Π° дСнь/нСдСлю/мСсяц:"
546 546
547 547 msgid "Subscribe to thread"
548 548 msgstr "ΠŸΠΎΠ΄ΠΏΠΈΡΠ°Ρ‚ΡŒΡΡ Π½Π° Ρ‚Π΅ΠΌΡƒ"
549 549
550 550 msgid "Active threads:"
551 551 msgstr "АктивныС Ρ‚Π΅ΠΌΡ‹:"
552 552
553 553 msgid "No active threads today."
554 554 msgstr "БСгодня Π½Π΅Ρ‚ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… Ρ‚Π΅ΠΌ."
555 555
556 556 msgid "Insert URLs on separate lines."
557 557 msgstr "ВставляйтС ссылки Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹Ρ… строках."
558 558
559 559 msgid "You can post no more than %(files)d file."
560 560 msgid_plural "You can post no more than %(files)d files."
561 561 msgstr[0] "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π½Π΅ Π±ΠΎΠ»Π΅Π΅ %(files)d Ρ„Π°ΠΉΠ»Π°."
562 562 msgstr[1] "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π½Π΅ Π±ΠΎΠ»Π΅Π΅ %(files)d Ρ„Π°ΠΉΠ»ΠΎΠ²."
563 563 msgstr[2] "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π½Π΅ Π±ΠΎΠ»Π΅Π΅ %(files)d Ρ„Π°ΠΉΠ»ΠΎΠ²."
564 564
565 565 #, python-format
566 566 msgid "Max file number is %(max_files)s."
567 567 msgstr "МаксимальноС количСство Ρ„Π°ΠΉΠ»ΠΎΠ² %(max_files)s."
568 568
569 569 msgid "Moderation"
570 570 msgstr "ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΡ"
571 571
572 572 msgid "Check for duplicates"
573 573 msgstr "ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΡ‚ΡŒ Π½Π° Π΄ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚Ρ‹"
574 574
575 575 msgid "Some files are already present on the board."
576 576 msgstr "НСкоторыС Ρ„Π°ΠΉΠ»Ρ‹ ΡƒΠΆΠ΅ ΠΏΡ€ΠΈΡΡƒΡ‚ΡΡ‚Π²ΡƒΡŽΡ‚ Π½Π° Π±ΠΎΡ€Π΄Π΅."
577 577
578 578 msgid "Do not download URLs"
579 579 msgstr "НС Π·Π°Π³Ρ€ΡƒΠΆΠ°Ρ‚ΡŒ ссылки"
580 580
581 581 msgid "Ban and delete"
582 582 msgstr "Π—Π°Π±Π°Π½ΠΈΡ‚ΡŒ ΠΈ ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ"
583 583
584 584 msgid "Are you sure?"
585 585 msgstr "Π’Ρ‹ ΡƒΠ²Π΅Ρ€Π΅Π½Ρ‹?"
586 586
587 587 msgid "Ban"
588 588 msgstr "Π—Π°Π±Π°Π½ΠΈΡ‚ΡŒ"
589 589
590 590 msgid "URL download mode"
591 591 msgstr "Π Π΅ΠΆΠΈΠΌ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ ссылок"
592 592
593 593 msgid "Download or add URL"
594 594 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ссылку"
595 595
596 596 msgid "Download or fail"
597 597 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ ΠΎΡ‚ΠΊΠ°Π·Π°Ρ‚ΡŒ"
598 598
599 599 msgid "Insert as URLs"
600 600 msgstr "Π’ΡΡ‚Π°Π²Π»ΡΡ‚ΡŒ ΠΊΠ°ΠΊ ссылки"
601 601
602 602 msgid "Help"
603 603 msgstr "Π‘ΠΏΡ€Π°Π²ΠΊΠ°"
604 604
605 605 msgid "View available stickers:"
606 606 msgstr "ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ доступныС стикСры:"
607 607
608 608 msgid "Stickers"
609 609 msgstr "Π‘Ρ‚ΠΈΠΊΠ΅Ρ€Ρ‹"
610 610
611 611 msgid "Available by addresses:"
612 612 msgstr "Доступно ΠΏΠΎ адрСсам:"
613 613
614 614 msgid "Local stickers"
615 615 msgstr "Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ стикСры"
616 616
617 617 msgid "Global stickers"
618 618 msgstr "Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Ρ‹Π΅ стикСры"
619 619
620 620 msgid "Remove sticker"
621 621 msgstr "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ стикСр"
622
623 msgid "Sticker Pack"
624 msgstr "Набор Π‘Ρ‚ΠΈΠΊΠ΅Ρ€ΠΎΠ²"
625
626 msgid "Tripcode should be specified to own a stickerpack."
627 msgstr "Для владСния Π½Π°Π±ΠΎΡ€ΠΎΠΌ стикСров Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΡƒΠΊΠ°Π·Π°Ρ‚ΡŒ Ρ‚Ρ€ΠΈΠΏΠΊΠΎΠ΄."
628
629 msgid "Title should be specified as a stickerpack name."
630 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΡƒΠΊΠ°Π·Π°Π½ Π² качСствС ΠΈΠΌΠ΅Π½ΠΈ Π½Π°Π±ΠΎΡ€Π° стикСров."
631
632 msgid "A sticker pack with this name already exists and is owned by another tripcode."
633 msgstr "Набор стикСров с Π΄Π°Π½Π½Ρ‹ΠΌ ΠΈΠΌΠ΅Π½Π΅ΠΌ ΡƒΠΆΠ΅ сущСствуСт ΠΈ ΠΏΡ€ΠΈΠ½Π°Π΄Π»Π΅ΠΆΠΈΡ‚ Π΄Ρ€ΡƒΠ³ΠΎΠΌΡƒ Ρ‚Ρ€ΠΈΠΏΠΊΠΎΠ΄Ρƒ."
634
635 msgid "This sticker pack can only be updated by an administrator."
636 msgstr "Π­Ρ‚ΠΎΡ‚ Π½Π°Π±ΠΎΡ€ стикСров ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΠΈΠ·ΠΌΠ΅Π½Ρ‘Π½ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ администратором."
637
638 msgid "To add a sticker, create a stickerpack thread using the title as a pack name, and a tripcode to own the pack. Then, add posts with title as a sticker name, and the same tripcode, to the thread. Their attachments would become stickers."
639 msgstr "Π§Ρ‚ΠΎΠ±Ρ‹ Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ стикСр, создайтС Ρ‚Π΅ΠΌΡƒ-Π½Π°Π±ΠΎΡ€ с Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΎΠΌ Π² качСствС названия Π½Π°Π±ΠΎΡ€Π° стикСров, ΠΈ Ρ‚Ρ€ΠΈΠΏΠΊΠΎΠ΄ΠΎΠΌ для подтвСрТдСния владСния Π½Π°Π±ΠΎΡ€ΠΎΠΌ. Π—Π°Ρ‚Π΅ΠΌ, добавляйтС сообщСния с Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΎΠΌ Π² качСствС ΠΈΠΌΠ΅Π½ΠΈ стикСра, ΠΈ Ρ‚Π΅ΠΌ ΠΆΠ΅ Ρ‚Ρ€ΠΈΠΏΠΊΠΎΠ΄ΠΎΠΌΠΌ. Π˜Ρ… влоТСния станут стикСрами."
640
641 msgid "Inappropriate sticker pack name."
642 msgstr "НСдопустимоС имя Π½Π°Π±ΠΎΡ€Π° стикСров."
1 NO CONTENT: modified file, binary diff hidden
@@ -1,621 +1,642 b''
1 1 # SOME DESCRIPTIVE TITLE.
2 2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 3 # This file is distributed under the same license as the PACKAGE package.
4 4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 5 #
6 6 msgid ""
7 7 msgstr ""
8 8 "Project-Id-Version: PACKAGE VERSION\n"
9 9 "Report-Msgid-Bugs-To: \n"
10 10 "POT-Creation-Date: 2015-10-09 23:21+0300\n"
11 11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 13 "Language-Team: LANGUAGE <LL@li.org>\n"
14 14 "Language: ru\n"
15 15 "MIME-Version: 1.0\n"
16 16 "Content-Type: text/plain; charset=UTF-8\n"
17 17 "Content-Transfer-Encoding: 8bit\n"
18 18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20 20
21 21 #: admin.py:22
22 22 msgid "{} posters were banned"
23 23 msgstr "{} постСрів Π·Π°Π±Π»ΠΎΠΊΠΎΠ²Π°Π½ΠΎ"
24 24
25 25 #: authors.py:9
26 26 msgid "author"
27 27 msgstr "Π°Π²Ρ‚ΠΎΡ€"
28 28
29 29 #: authors.py:10
30 30 msgid "developer"
31 31 msgstr "Ρ€ΠΎΠ·Ρ€ΠΎΠ±Π½ΠΈΠΊ"
32 32
33 33 #: authors.py:11
34 34 msgid "javascript developer"
35 35 msgstr "javascript-Ρ€ΠΎΠ·Ρ€ΠΎΠ±Π½ΠΈΠΊ"
36 36
37 37 #: authors.py:12
38 38 msgid "designer"
39 39 msgstr "Π΄ΠΈΠ·Π°ΠΉΠ½Π΅Ρ€"
40 40
41 41 #: forms.py:30
42 42 msgid "Type message here. Use formatting panel for more advanced usage."
43 43 msgstr ""
44 44 "Π’Π²Π΅Π΄Ρ–Ρ‚ΡŒ сюди повідомлСння. ΠšΠΎΡ€ΠΈΡΡ‚Π°ΠΉΡ‚Π΅ панСль для ΡΠΊΠ»Π°Π΄Π½Ρ–ΡˆΠΎΠ³ΠΎ форматування."
45 45
46 46 #: forms.py:31
47 47 msgid "music images i_dont_like_tags"
48 48 msgstr "ΠΌΡƒΠ·ΠΈΠΊΠ° зобраТСння ΠΌΡ–Ρ‚ΠΊΠΈ_Π½Π΅_ΠΏΠΎΡ‚Ρ€Ρ–Π±Π½Ρ–"
49 49
50 50 #: forms.py:33
51 51 msgid "Title"
52 52 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ"
53 53
54 54 #: forms.py:34
55 55 msgid "Text"
56 56 msgstr "ВСкст"
57 57
58 58 #: forms.py:35
59 59 msgid "Tag"
60 60 msgstr "ΠœΡ–Ρ‚ΠΊΠ°"
61 61
62 62 #: forms.py:36 templates/boards/base.html:40 templates/search/search.html:7
63 63 msgid "Search"
64 64 msgstr "ΠŸΠΎΡˆΡƒΠΊ"
65 65
66 66 #: forms.py:48
67 67 msgid "File 1"
68 68 msgstr "Π€Π°ΠΉΠ» 1"
69 69
70 70 #: forms.py:48
71 71 msgid "File 2"
72 72 msgstr "Π€Π°ΠΉΠ» 2"
73 73
74 74 #: forms.py:142
75 75 msgid "File URL"
76 76 msgstr "URL Ρ„Π°ΠΉΠ»Ρƒ"
77 77
78 78 #: forms.py:148
79 79 msgid "e-mail"
80 80 msgstr ""
81 81
82 82 #: forms.py:151
83 83 msgid "Additional threads"
84 84 msgstr "Π”ΠΎΠ΄Π°Ρ‚ΠΊΠΎΠ²Ρ– Π½ΠΈΡ‚ΠΊΠΈ"
85 85
86 86 #: forms.py:162
87 87 #, python-format
88 88 msgid "Title must have less than %s characters"
89 89 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ ΠΌΠ°Ρ” містити мСншС %s символів"
90 90
91 91 #: forms.py:172
92 92 #, python-format
93 93 msgid "Text must have less than %s characters"
94 94 msgstr "ВСкст ΠΌΠ°Ρ” Π±ΡƒΡ‚ΠΈ ΠΊΠΎΡ€ΠΎΡ‚ΡˆΠ΅ %s символів"
95 95
96 96 #: forms.py:192
97 97 msgid "Invalid URL"
98 98 msgstr "Π₯ΠΈΠ±Π½ΠΈΠΉ URL"
99 99
100 100 #: forms.py:213
101 101 msgid "Invalid additional thread list"
102 102 msgstr "Π₯ΠΈΠ±Π½ΠΈΠΉ ΠΏΠ΅Ρ€Π΅Π»Ρ–ΠΊ Π΄ΠΎΠ΄Π°Ρ‚ΠΊΠΎΠ²ΠΈΡ… Π½ΠΈΡ‚ΠΎΠΊ"
103 103
104 104 #: forms.py:258
105 105 msgid "Either text or file must be entered."
106 106 msgstr "Π‘Π»Ρ–Π΄ Π΄ΠΎΠ΄Π°Ρ‚ΠΈ тСкст Π°Π±ΠΎ Ρ„Π°ΠΉΠ»."
107 107
108 108 #: forms.py:317 templates/boards/all_threads.html:153
109 109 #: templates/boards/rss/post.html:10 templates/boards/tags.html:6
110 110 msgid "Tags"
111 111 msgstr "ΠœΡ–Ρ‚ΠΊΠΈ"
112 112
113 113 #: forms.py:324
114 114 msgid "Inappropriate characters in tags."
115 115 msgstr "НСприйнятні символи Ρƒ ΠΌΡ–Ρ‚ΠΊΠ°Ρ…."
116 116
117 117 #: forms.py:344
118 118 msgid "Need at least one section."
119 119 msgstr "ΠœΡƒΡΠΈΡ‚ΡŒ Π±ΡƒΡ‚ΠΈ Ρ…ΠΎΡ‡Π° Π± ΠΎΠ΄ΠΈΠ½ Ρ€ΠΎΠ·Π΄Ρ–Π»."
120 120
121 121 #: forms.py:356
122 122 msgid "Theme"
123 123 msgstr "Π’Π΅ΠΌΠ°"
124 124
125 125 #: forms.py:357
126 126 msgid "Image view mode"
127 127 msgstr "Π Π΅ΠΆΠΈΠΌ пСрСгляду Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΡŒ"
128 128
129 129 #: forms.py:358
130 130 msgid "User name"
131 131 msgstr "Им'я користувача"
132 132
133 133 #: forms.py:359
134 134 msgid "Time zone"
135 135 msgstr "Часовий пояс"
136 136
137 137 #: forms.py:365
138 138 msgid "Inappropriate characters."
139 139 msgstr "НСприйнятні символи."
140 140
141 141 #: templates/boards/404.html:6
142 142 msgid "Not found"
143 143 msgstr "Загубилося"
144 144
145 145 #: templates/boards/404.html:12
146 146 msgid "This page does not exist"
147 147 msgstr "НСма ΠΏΡ€Π°Π²Π΄ΠΎΠ½ΡŒΠΊΠΈ Π½Π° світі, ΠΎΠΉ нСма…"
148 148
149 149 #: templates/boards/all_threads.html:35
150 150 msgid "Details"
151 151 msgstr "Π”Π΅Ρ‚Π°Π»Ρ–"
152 152
153 153 #: templates/boards/all_threads.html:69
154 154 msgid "Edit tag"
155 155 msgstr "Π—ΠΌΡ–Π½ΠΈΡ‚ΠΈ ΠΌΡ–Ρ‚ΠΊΡƒ"
156 156
157 157 #: templates/boards/all_threads.html:76
158 158 #, python-format
159 159 msgid "%(count)s active thread"
160 160 msgid_plural "%(count)s active threads"
161 161 msgstr[0] "%(count)s Π°ΠΊΡ‚ΠΈΠ²Π½Π° Π½ΠΈΡ‚ΠΊΠ°"
162 162 msgstr[1] "%(count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ– Π½ΠΈΡ‚ΠΊΠΈ"
163 163 msgstr[2] "%(count)s Π°ΠΊΡ‚ΠΈΠ²Π½ΠΈΡ… Π½ΠΈΡ‚ΠΎΠΊ"
164 164
165 165 #: templates/boards/all_threads.html:76
166 166 #, python-format
167 167 msgid "%(count)s thread in bumplimit"
168 168 msgid_plural "%(count)s threads in bumplimit"
169 169 msgstr[0] "%(count)s Π½ΠΈΡ‚ΠΊΠ° Π² бампляматі"
170 170 msgstr[1] "%(count)s Π½ΠΈΡ‚ΠΊΠΈ Π² бампляматі"
171 171 msgstr[2] "%(count)s Π½ΠΈΡ‚ΠΎΠΊ Ρƒ бампляматі"
172 172
173 173 #: templates/boards/all_threads.html:77
174 174 #, python-format
175 175 msgid "%(count)s archived thread"
176 176 msgid_plural "%(count)s archived thread"
177 177 msgstr[0] "%(count)s Π°Ρ€Ρ…Ρ–Π²Π½Π° Π½ΠΈΡ‚ΠΊΠ°"
178 178 msgstr[1] "%(count)s Π°Ρ€Ρ…Ρ–Π²Π½Ρ– Π½ΠΈΡ‚ΠΊΠΈ"
179 179 msgstr[2] "%(count)s Π°Ρ€Ρ…Ρ–Π²Π½ΠΈΡ… Π½ΠΈΡ‚ΠΎΠΊ"
180 180
181 181 #: templates/boards/all_threads.html:78 templates/boards/post.html:102
182 182 #, python-format
183 183 #| msgid "%(count)s message"
184 184 #| msgid_plural "%(count)s messages"
185 185 msgid "%(count)s message"
186 186 msgid_plural "%(count)s messages"
187 187 msgstr[0] "%(count)s повідомлСння"
188 188 msgstr[1] "%(count)s повідомлСння"
189 189 msgstr[2] "%(count)s ΠΏΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ"
190 190
191 191 #: templates/boards/all_threads.html:95 templates/boards/feed.html:30
192 192 #: templates/boards/notifications.html:17 templates/search/search.html:26
193 193 msgid "Previous page"
194 194 msgstr "ΠŸΠΎΠΏΡ”Ρ€Ρ”Π΄Π½Ρ сторінка"
195 195
196 196 #: templates/boards/all_threads.html:109
197 197 #, python-format
198 198 msgid "Skipped %(count)s reply. Open thread to see all replies."
199 199 msgid_plural "Skipped %(count)s replies. Open thread to see all replies."
200 200 msgstr[0] "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄ΡŒ. Π ΠΎΠ·Π³ΠΎΡ€Π½Ρ–Ρ‚ΡŒ Π½ΠΈΡ‚ΠΊΡƒ, Ρ‰ΠΎΠ± ΠΏΠΎΠ±Π°Ρ‡ΠΈΡ‚ΠΈ всі Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Ρ–."
201 201 msgstr[1] ""
202 202 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Ρ–. Π ΠΎΠ·Π³ΠΎΡ€Π½Ρ–Ρ‚ΡŒ Π½ΠΈΡ‚ΠΊΡƒ, Ρ‰ΠΎΠ± ΠΏΠΎΠ±Π°Ρ‡ΠΈΡ‚ΠΈ всі Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Ρ–."
203 203 msgstr[2] ""
204 204 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Π΅ΠΉ. Π ΠΎΠ·Π³ΠΎΡ€Π½Ρ–Ρ‚ΡŒ Π½ΠΈΡ‚ΠΊΡƒ, Ρ‰ΠΎΠ± ΠΏΠΎΠ±Π°Ρ‡ΠΈΡ‚ΠΈ всі Π²Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Ρ–."
205 205
206 206 #: templates/boards/all_threads.html:127 templates/boards/feed.html:40
207 207 #: templates/boards/notifications.html:27 templates/search/search.html:37
208 208 msgid "Next page"
209 209 msgstr "Наступна сторінка"
210 210
211 211 #: templates/boards/all_threads.html:132
212 212 msgid "No threads exist. Create the first one!"
213 213 msgstr "НСма ΠΏΡ€Π°Π²Π΄ΠΎΠ½ΡŒΠΊΠΈ Π½Π° світі. Π—Π°Ρ‡Π½Ρ–ΠΌΠΎ Ρ—Ρ—!"
214 214
215 215 #: templates/boards/all_threads.html:138
216 216 msgid "Create new thread"
217 217 msgstr "БплСсти Π½ΠΎΠ²Ρƒ Π½ΠΈΡ‚ΠΊΡƒ"
218 218
219 219 #: templates/boards/all_threads.html:143 templates/boards/preview.html:16
220 220 #: templates/boards/thread_normal.html:51
221 221 msgid "Post"
222 222 msgstr "Надіслати"
223 223
224 224 #: templates/boards/all_threads.html:144 templates/boards/preview.html:6
225 225 #: templates/boards/staticpages/help.html:21
226 226 #: templates/boards/thread_normal.html:52
227 227 msgid "Preview"
228 228 msgstr "ΠŸΠΎΠΏΠ΅Ρ€Π΅Π³Π»ΡΠ΄"
229 229
230 230 #: templates/boards/all_threads.html:149
231 231 msgid "Tags must be delimited by spaces. Text or image is required."
232 232 msgstr ""
233 233 "ΠœΡ–Ρ‚ΠΊΠΈ Ρ€ΠΎΠ·ΠΌΠ΅ΠΆΡƒΠ²Π°Ρ‚ΠΈ ΠΏΡ€ΠΎΠ±Ρ–Π»Π°ΠΌΠΈ. ВСкст Ρ‡ΠΈ зобраТСння Ρ” ΠΎΠ±ΠΎΠ²'язковими."
234 234
235 235 #: templates/boards/all_threads.html:152 templates/boards/thread_normal.html:58
236 236 msgid "Text syntax"
237 237 msgstr "Бинтаксис тСксту"
238 238
239 239 #: templates/boards/all_threads.html:166 templates/boards/feed.html:53
240 240 msgid "Pages:"
241 241 msgstr "Π‘Ρ‚ΠΎΡ€Ρ–Π½ΠΊΠΈ:"
242 242
243 243 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
244 244 msgid "Authors"
245 245 msgstr "Автори"
246 246
247 247 #: templates/boards/authors.html:26
248 248 msgid "Distributed under the"
249 249 msgstr "Π ΠΎΠ·ΠΏΠΎΠ²ΡΡŽΠ΄ΠΆΡƒΡ”Ρ‚ΡŒΡΡ ΠΏΡ–Π΄ Π»Ρ–Ρ†Π΅Π½Π·Ρ–Ρ”ΡŽ"
250 250
251 251 #: templates/boards/authors.html:28
252 252 msgid "license"
253 253 msgstr ""
254 254
255 255 #: templates/boards/authors.html:30
256 256 msgid "Repository"
257 257 msgstr "Π Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€Ρ–ΠΉ"
258 258
259 259 #: templates/boards/base.html:14 templates/boards/base.html.py:41
260 260 msgid "Feed"
261 261 msgstr "Π‘Ρ‚Ρ€Ρ–Ρ‡ΠΊΠ°"
262 262
263 263 #: templates/boards/base.html:31
264 264 msgid "All threads"
265 265 msgstr "Усі Π½ΠΈΡ‚ΠΊΠΈ"
266 266
267 267 #: templates/boards/base.html:37
268 268 msgid "Add tags"
269 269 msgstr "Π”ΠΎΠ΄Π°Ρ‚ΠΈ ΠΌΡ–Ρ‚ΠΊΠΈ"
270 270
271 271 #: templates/boards/base.html:39
272 272 msgid "Tag management"
273 273 msgstr "ΠšΠ΅Ρ€ΡƒΠ²Π°Π½Π½Ρ ΠΌΡ–Ρ‚ΠΊΠ°ΠΌΠΈ"
274 274
275 275 #: templates/boards/base.html:39
276 276 msgid "tags"
277 277 msgstr "ΠΌΡ–Ρ‚ΠΊΠΈ"
278 278
279 279 #: templates/boards/base.html:40
280 280 msgid "search"
281 281 msgstr "ΠΏΠΎΡˆΡƒΠΊ"
282 282
283 283 #: templates/boards/base.html:41 templates/boards/feed.html:11
284 284 msgid "feed"
285 285 msgstr "стрічка"
286 286
287 287 #: templates/boards/base.html:42 templates/boards/random.html:6
288 288 msgid "Random images"
289 289 msgstr "Π’ΠΈΠΏΠ°Π΄ΠΊΠΎΠ²Ρ– зобраТСння"
290 290
291 291 #: templates/boards/base.html:42
292 292 msgid "random"
293 293 msgstr "Π²ΠΈΠΏΠ°Π΄ΠΊΠΎΠ²Ρ–"
294 294
295 295 #: templates/boards/base.html:44
296 296 msgid "favorites"
297 297 msgstr "ΡƒΠ»ΡŽΠ±Π»Π΅Π½Π΅"
298 298
299 299 #: templates/boards/base.html:48 templates/boards/base.html.py:49
300 300 #: templates/boards/notifications.html:8
301 301 msgid "Notifications"
302 302 msgstr "БповіщСння"
303 303
304 304 #: templates/boards/base.html:56 templates/boards/settings.html:8
305 305 msgid "Settings"
306 306 msgstr "ΠΠ°Π»Π°ΡˆΡ‚ΡƒΠ²Π°Π½Π½Ρ"
307 307
308 308 #: templates/boards/base.html:59
309 309 msgid "Loading..."
310 310 msgstr "ЗавантаТСння..."
311 311
312 312 #: templates/boards/base.html:71
313 313 msgid "Admin"
314 314 msgstr "Адміністрування"
315 315
316 316 #: templates/boards/base.html:73
317 317 #, python-format
318 318 msgid "Speed: %(ppd)s posts per day"
319 319 msgstr "Π₯ΡƒΡ‚ΠΊΡ–ΡΡ‚ΡŒ: %(ppd)s ΠΏΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ Π½Π° дСнь"
320 320
321 321 #: templates/boards/base.html:75
322 322 msgid "Up"
323 323 msgstr "Π”ΠΎΠ³ΠΎΡ€ΠΈ"
324 324
325 325 #: templates/boards/feed.html:45
326 326 msgid "No posts exist. Create the first one!"
327 327 msgstr "Π©Π΅ Π½Π΅ΠΌΠ° ΠΏΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ. Π—Π°Ρ‡Π½Ρ–ΠΌΠΎ!"
328 328
329 329 #: templates/boards/post.html:33
330 330 msgid "Open"
331 331 msgstr "Π’Ρ–Π΄ΠΊΡ€ΠΈΡ‚ΠΈ"
332 332
333 333 #: templates/boards/post.html:35 templates/boards/post.html.py:46
334 334 msgid "Reply"
335 335 msgstr "Відповісти"
336 336
337 337 #: templates/boards/post.html:41
338 338 msgid " in "
339 339 msgstr " Ρƒ "
340 340
341 341 #: templates/boards/post.html:51
342 342 msgid "Edit"
343 343 msgstr "Π—ΠΌΡ–Π½ΠΈΡ‚ΠΈ"
344 344
345 345 #: templates/boards/post.html:53
346 346 msgid "Edit thread"
347 347 msgstr "Π—ΠΌΡ–Π½ΠΈΡ‚ΠΈ Π½ΠΈΡ‚ΠΊΡƒ"
348 348
349 349 #: templates/boards/post.html:91
350 350 msgid "Replies"
351 351 msgstr "Π’Ρ–Π΄ΠΏΠΎΠ²Ρ–Π΄Ρ–"
352 352
353 353 #: templates/boards/post.html:103
354 354 #, python-format
355 355 msgid "%(count)s image"
356 356 msgid_plural "%(count)s images"
357 357 msgstr[0] "%(count)s зобраТСння"
358 358 msgstr[1] "%(count)s зобраТСння"
359 359 msgstr[2] "%(count)s Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΡŒ"
360 360
361 361 #: templates/boards/rss/post.html:5
362 362 msgid "Post image"
363 363 msgstr "ЗобраТСння повідомлСння"
364 364
365 365 #: templates/boards/settings.html:15
366 366 msgid "You are moderator."
367 367 msgstr "Π’ΠΈ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ‚ΠΎΡ€."
368 368
369 369 #: templates/boards/settings.html:19
370 370 msgid "Hidden tags:"
371 371 msgstr "ΠŸΡ€ΠΈΡ…ΠΎΠ²Π°Π½Ρ– ΠΌΡ–Ρ‚ΠΊΠΈ:"
372 372
373 373 #: templates/boards/settings.html:25
374 374 msgid "No hidden tags."
375 375 msgstr "НСма ΠΏΡ€ΠΈΡ…ΠΎΠ²Π°Π½ΠΈΡ… ΠΌΡ–Ρ‚ΠΎΠΊ."
376 376
377 377 #: templates/boards/settings.html:34
378 378 msgid "Save"
379 379 msgstr "Π—Π±Π΅Ρ€Π΅Π³Ρ‚ΠΈ"
380 380
381 381 #: templates/boards/staticpages/banned.html:6
382 382 msgid "Banned"
383 383 msgstr "Π—Π°Π±Π»ΠΎΠΊΠΎΠ²Π°Π½ΠΎ"
384 384
385 385 #: templates/boards/staticpages/banned.html:11
386 386 msgid "Your IP address has been banned. Contact the administrator"
387 387 msgstr "Π’Π°ΡˆΡƒ IP-адрСсу Π·Π°Π±Π»ΠΎΠΊΠΎΠ²Π°Π½ΠΎ. Π—Π°Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½ΡƒΠΉΡ‚Π΅ Π΄ΠΎ спортлото"
388 388
389 389 #: templates/boards/staticpages/help.html:6
390 390 #: templates/boards/staticpages/help.html:10
391 391 msgid "Syntax"
392 392 msgstr "Бинтаксис"
393 393
394 394 #: templates/boards/staticpages/help.html:11
395 395 msgid "Italic text"
396 396 msgstr "ΠšΡƒΡ€ΡΠΈΠ²Π½ΠΈΠΉ тСкст"
397 397
398 398 #: templates/boards/staticpages/help.html:12
399 399 msgid "Bold text"
400 400 msgstr "Напівогрядний тСкст"
401 401
402 402 #: templates/boards/staticpages/help.html:13
403 403 msgid "Spoiler"
404 404 msgstr "Π‘ΠΏΠΎΠΉΠ»Π΅Ρ€"
405 405
406 406 #: templates/boards/staticpages/help.html:14
407 407 msgid "Link to a post"
408 408 msgstr "Посилання Π½Π° повідомлСння"
409 409
410 410 #: templates/boards/staticpages/help.html:15
411 411 msgid "Strikethrough text"
412 412 msgstr "ЗакрСслСний тСкст"
413 413
414 414 #: templates/boards/staticpages/help.html:16
415 415 msgid "Comment"
416 416 msgstr "ΠšΠΎΠΌΠ΅Π½Ρ‚Π°Ρ€"
417 417
418 418 #: templates/boards/staticpages/help.html:17
419 419 #: templates/boards/staticpages/help.html:18
420 420 msgid "Quote"
421 421 msgstr "Π¦ΠΈΡ‚Π°Ρ‚Π°"
422 422
423 423 #: templates/boards/staticpages/help.html:21
424 424 msgid "You can try pasting the text and previewing the result here:"
425 425 msgstr "ΠœΠΎΠΆΠ΅Ρ‚Π΅ спробувати вставити тСкст Ρ– ΠΏΠ΅Ρ€Π΅Π²Ρ–Ρ€ΠΈΡ‚ΠΈ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ Ρ‚ΡƒΡ‚:"
426 426
427 427 #: templates/boards/thread.html:14
428 428 msgid "Normal"
429 429 msgstr "Π—Π²ΠΈΡ‡Π°ΠΉΠ½ΠΈΠΉ"
430 430
431 431 #: templates/boards/thread.html:15
432 432 msgid "Gallery"
433 433 msgstr "ГалСрСя"
434 434
435 435 #: templates/boards/thread.html:16
436 436 msgid "Tree"
437 437 msgstr "Π’Ρ–Π½ΠΈΠΊ"
438 438
439 439 #: templates/boards/thread.html:35
440 440 msgid "message"
441 441 msgid_plural "messages"
442 442 msgstr[0] "повідомлСння"
443 443 msgstr[1] "повідомлСння"
444 444 msgstr[2] "ΠΏΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ"
445 445
446 446 #: templates/boards/thread.html:38
447 447 msgid "image"
448 448 msgid_plural "images"
449 449 msgstr[0] "зобраТСння"
450 450 msgstr[1] "зобраТСння"
451 451 msgstr[2] "Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΡŒ"
452 452
453 453 #: templates/boards/thread.html:40
454 454 msgid "Last update: "
455 455 msgstr "ΠžΡΡ‚Π°Π½Π½Ρ” оновлСння: "
456 456
457 457 #: templates/boards/thread_gallery.html:36
458 458 msgid "No images."
459 459 msgstr "НСма Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΡŒ."
460 460
461 461 #: templates/boards/thread_normal.html:30
462 462 msgid "posts to bumplimit"
463 463 msgstr "ΠΏΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ Π΄ΠΎ бамплямату"
464 464
465 465 #: templates/boards/thread_normal.html:44
466 466 msgid "Reply to thread"
467 467 msgstr "Відповісти Π΄ΠΎ Π½ΠΈΡ‚ΠΊΠΈ"
468 468
469 469 #: templates/boards/thread_normal.html:44
470 470 msgid "to message "
471 471 msgstr "Π½Π° повідомлСння"
472 472
473 473 #: templates/boards/thread_normal.html:59
474 474 msgid "Reset form"
475 475 msgstr "Π‘ΠΊΠΈΠ½ΡƒΡ‚ΠΈ Ρ„ΠΎΡ€ΠΌΡƒ"
476 476
477 477 #: templates/search/search.html:17
478 478 msgid "Ok"
479 479 msgstr "Π€Π°ΠΉΠ½ΠΎ"
480 480
481 481 #: utils.py:120
482 482 #, python-format
483 483 msgid "File must be less than %s but is %s."
484 484 msgstr "Π€Π°ΠΉΠ» ΠΌΡƒΡΠΈΡ‚ΡŒ Π±ΡƒΡ‚ΠΈ мСншС %s, Π°Π»Π΅ ΠΉΠΎΠ³ΠΎ Ρ€ΠΎΠ·ΠΌΡ–Ρ€ %s."
485 485
486 486 msgid "Please wait %(delay)d second before sending message"
487 487 msgid_plural "Please wait %(delay)d seconds before sending message"
488 488 msgstr[0] "Π—Π°Ρ‡Π΅ΠΊΠ°ΠΉΡ‚Π΅, Π±ΡƒΠ΄ΡŒ ласка, %(delay)d сСкунду ΠΏΠ΅Ρ€Π΅Π΄ надсиланням повідомлСння"
489 489 msgstr[1] "Π—Π°Ρ‡Π΅ΠΊΠ°ΠΉΡ‚Π΅, Π±ΡƒΠ΄ΡŒ ласка, %(delay)d сСкунди ΠΏΠ΅Ρ€Π΅Π΄ надсиланням повідомлСння"
490 490 msgstr[2] "Π—Π°Ρ‡Π΅ΠΊΠ°ΠΉΡ‚Π΅, Π±ΡƒΠ΄ΡŒ ласка, %(delay)d сСкунд ΠΏΠ΅Ρ€Π΅Π΄ надсиланням повідомлСння"
491 491
492 492 msgid "New threads"
493 493 msgstr "Нові Π½ΠΈΡ‚ΠΊΠΈ"
494 494
495 495 #, python-format
496 496 msgid "Max file size is %(size)s."
497 497 msgstr "Максимальний Ρ€ΠΎΠ·ΠΌΡ–Ρ€ Ρ„Π°ΠΉΠ»Ρƒ %(size)s."
498 498
499 499 msgid "Size of media:"
500 500 msgstr "Π ΠΎΠ·ΠΌΡ–Ρ€ посСрСдника:"
501 501
502 502 msgid "Statistics"
503 503 msgstr "Бтатистика"
504 504
505 505 msgid "Invalid PoW."
506 506 msgstr "Π₯ΠΈΠ±Π½ΠΈΠΉ PoW."
507 507
508 508 msgid "Stale PoW."
509 509 msgstr "PoW застарів."
510 510
511 511 msgid "Show"
512 512 msgstr "ΠŸΠΎΠΊΠ°Π·ΡƒΠ²Π°Ρ‚ΠΈ"
513 513
514 514 msgid "Hide"
515 515 msgstr "Π₯ΠΎΠ²Π°Ρ‚ΠΈ"
516 516
517 517 msgid "Add to favorites"
518 518 msgstr "Π― Ρ†Π΅ люблю"
519 519
520 520 msgid "Remove from favorites"
521 521 msgstr "Π’ΠΆΠ΅ Π½Π΅ люблю"
522 522
523 523 msgid "Monochrome"
524 524 msgstr "Π‘Π΅Π· Π±Π°Ρ€Π²"
525 525
526 526 msgid "Subsections: "
527 527 msgstr "ΠŸΡ–Π΄Ρ€ΠΎΠ·Π΄Ρ–Π»ΠΈ: "
528 528
529 529 msgid "Change file source"
530 530 msgstr "Π—ΠΌΡ–Π½ΠΈΡ‚ΠΈ Π΄ΠΆΠ΅Ρ€Π΅Π»ΠΎ Ρ„Π°ΠΉΠ»Ρƒ"
531 531
532 532 msgid "interesting"
533 533 msgstr "Ρ†Ρ–ΠΊΠ°Π²Π΅"
534 534
535 535 msgid "images"
536 536 msgstr "ΠΏΡ–Ρ‡ΠΊΡƒΡ€ΠΈ"
537 537
538 538 msgid "Delete post"
539 539 msgstr "Π’ΠΈΠ΄Π°Π»ΠΈΡ‚ΠΈ повідомлСння"
540 540
541 541 msgid "Delete thread"
542 542 msgstr "Π’ΠΈΡ€Π²Π°Ρ‚ΠΈ Π½ΠΈΡ‚ΠΊΡƒ"
543 543
544 544 msgid "Messages per day/week/month:"
545 545 msgstr "ΠŸΠΎΠ²Ρ–Π΄ΠΎΠΌΠ»Π΅Π½ΡŒ Π·Π° дСнь/Ρ‚ΠΈΠΆΠ΄Π΅Π½ΡŒ/Ρ‚ΠΈΠΆΠΌΡ–ΡΡΡ†ΡŒ:"
546 546
547 547 msgid "Subscribe to thread"
548 548 msgstr "Π‘Ρ‚Π΅ΠΆΠΈΡ‚ΠΈ Π·Π° Π½ΠΈΡ‚ΠΊΠΎΡŽ"
549 549
550 550 msgid "Active threads:"
551 551 msgstr "Активні Π½ΠΈΡ‚ΠΊΠΈ:"
552 552
553 553 msgid "No active threads today."
554 554 msgstr "Щось усі Π·Π°ΠΌΠΎΠ²ΠΊΠ»ΠΈ."
555 555
556 556 msgid "Insert URLs on separate lines."
557 557 msgstr "ВставляйтС посилання ΠΎΠΊΡ€Π΅ΠΌΠΈΠΌΠΈ рядками."
558 558
559 559 msgid "You can post no more than %(files)d file."
560 560 msgid_plural "You can post no more than %(files)d files."
561 561 msgstr[0] "Π’ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ надіслати Π½Π΅ Π±Ρ–Π»ΡŒΡˆΠ΅ %(files)d Ρ„Π°ΠΉΠ»Ρƒ."
562 562 msgstr[1] "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ надіслати Π½Π΅ Π±Ρ–Π»ΡŒΡˆΠ΅ %(files)d Ρ„Π°ΠΉΠ»Ρ–Π²."
563 563 msgstr[2] "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ надіслати Π½Π΅ Π±Ρ–Π»ΡŒΡˆΠ΅ %(files)d Ρ„Π°ΠΉΠ»Ρ–Π²."
564 564
565 565 #, python-format
566 566 msgid "Max file number is %(max_files)s."
567 567 msgstr "Максимальна ΠΊΡ–Π»ΡŒΠΊΡ–ΡΡ‚ΡŒ Ρ„Π°ΠΉΠ»Ρ–Π² %(max_files)s."
568 568
569 569 msgid "Moderation"
570 570 msgstr "ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†Ρ–Ρ"
571 571
572 572 msgid "Check for duplicates"
573 573 msgstr "ΠŸΠ΅Ρ€Π΅Π²Ρ–Ρ€ΡΡ‚ΠΈ Π½Π° Π΄ΡƒΠ±Π»Ρ–ΠΊΠ°Ρ‚ΠΈ"
574 574
575 575 msgid "Some files are already present on the board."
576 576 msgstr "ДСякі Ρ„Π°ΠΉΠ»ΠΈ Π²ΠΆΠ΅ Ρ” Π½Π° Π΄ΠΎΡˆΡ†Ρ–."
577 577
578 578 msgid "Do not download URLs"
579 579 msgstr "НС Π·Π°Π²Π°Π½Ρ‚Π°ΠΆΡƒΠ²Π°Ρ‚ΠΈ посилання"
580 580
581 581 msgid "Ban and delete"
582 582 msgstr "Π—Π°Π±Π»ΠΎΠΊΡƒΠ²Π°Ρ‚ΠΈ ΠΉ Π²ΠΈΠ΄Π°Π»ΠΈΡ‚ΠΈ"
583 583
584 584 msgid "Are you sure?"
585 585 msgstr "Π§ΠΈ Π²ΠΈ ΠΏΠ΅Π²Π½Ρ–?"
586 586
587 587 msgid "Ban"
588 588 msgstr "Π—Π°Π±Π»ΠΎΠΊΡƒΠ²Π°Ρ‚ΠΈ"
589 589
590 590 msgid "URL download mode"
591 591 msgstr "Π Π΅ΠΆΠΈΠΌ завантаТСння посилань"
592 592
593 593 msgid "Download or add URL"
594 594 msgstr "Π—Π°Π²Π°Π½Ρ‚Π°ΠΆΠΈΡ‚ΠΈ Π°Π±ΠΎ Π΄ΠΎΠ΄Π°Ρ‚ΠΈ посилання"
595 595
596 596 msgid "Download or fail"
597 597 msgstr "Π—Π°Π²Π°Π½Ρ‚Π°ΠΆΠΈΡ‚ΠΈ Π°Π±ΠΎ Π²Ρ–Π΄ΠΌΠΎΠ²ΠΈΡ‚ΠΈ"
598 598
599 599 msgid "Insert as URLs"
600 600 msgstr "Вставляти як посилання"
601 601
602 602 msgid "Help"
603 603 msgstr "Π‘ΠΏΡ€Π°Π²ΠΊΠ°"
604 604
605 605 msgid "View available stickers:"
606 606 msgstr "ΠŸΠ΅Ρ€Π΅Π΄ΠΈΠ²ΠΈΡ‚ΠΈΡΡ доступні стікСри:"
607 607
608 608 msgid "Stickers"
609 609 msgstr "Π‘Ρ‚Ρ–ΠΊΠ΅Ρ€ΠΈ"
610 610
611 611 msgid "Available by addresses:"
612 612 msgstr "Доступно Π·Π° адрСсами:"
613 613
614 614 msgid "Local stickers"
615 615 msgstr "Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ– стікСри"
616 616
617 617 msgid "Global stickers"
618 618 msgstr "Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Ρ– стікСри"
619 619
620 620 msgid "Remove sticker"
621 621 msgstr "Π’ΠΈΠ΄Π°Π»ΠΈΡ‚ΠΈ стікСр"
622
623 msgid "Sticker Pack"
624 msgstr "Набір Π‘Ρ‚Ρ–ΠΊΠ΅Ρ€Ρ–Π²"
625
626 msgid "Tripcode should be specified to own a stickerpack."
627 msgstr "Для володіння Π½Π°Π±ΠΎΡ€ΠΎΠΌ стікСрів Π½Π΅ΠΎΠ±Ρ…Ρ–Π΄Π½ΠΎ Π²ΠΊΠ°Π·Π°Ρ‚ΠΈ Ρ‚Ρ€Ρ–ΠΏΠΊΠΎΠ΄."
628
629 msgid "Title should be specified as a stickerpack name."
630 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ ΠΏΠΎΠ²ΠΈΠ½Π΅Π½ Π±ΡƒΡ‚ΠΈ Π²ΠΊΠ°Π·Π°Π½ΠΈΠΉ Π² якості Ρ–ΠΌΠ΅Π½Ρ– Π½Π°Π±ΠΎΡ€Ρƒ стікСрів."
631
632 msgid "A sticker pack with this name already exists and is owned by another tripcode."
633 msgstr "Набір стікСрів Π· Π²ΠΊΠ°Π·Π°Π½ΠΈΠΌ Ρ–ΠΌΠ΅Π½Π΅ΠΌ Π²ΠΆΠ΅ Ρ” Π² наявності Ρ‚Π° Π½Π°Π»Π΅ΠΆΠΈΡ‚ΡŒ Ρ–Π½ΡˆΠΎΠΌΡƒ Ρ‚Ρ€Ρ–ΠΏΠΊΠΎΠ΄Ρƒ."
634
635 msgid "This sticker pack can only be updated by an administrator."
636 msgstr "Π¦Π΅ΠΉ Π½Π°Π±Ρ–Ρ€ стікСрів ΠΌΠΎΠΆΠ΅ Π±ΡƒΡ‚ΠΈ Π·ΠΌΡ–Π½Π΅Π½ΠΈΠΉ лишС адміністратором."
637
638 msgid "To add a sticker, create a stickerpack thread using the title as a pack name, and a tripcode to own the pack. Then, add posts with title as a sticker name, and the same tripcode, to the thread. Their attachments would become stickers."
639 msgstr "Π©ΠΎΠ± Π΄ΠΎΠ΄Π°Ρ‚ΠΈ стікСр, ΡΡ‚Π²ΠΎΡ€Ρ–Ρ‚ΡŒ Ρ‚Π΅ΠΌΡƒ-Π½Π°Π±Ρ–Ρ€ Π· Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΎΠΌ Ρƒ якості Π½Π°Π±ΠΎΡ€Ρƒ стікСрів, Ρ‚Π° Ρ‚Ρ€Ρ–ΠΏΠΊΠΎΠ΄ΠΎΠΌ для підтвСрдТСння володіння Π½Π°Π±ΠΎΡ€ΠΎΠΌΡ‚. ΠŸΠΎΡ‚Ρ–ΠΌ, Π΄ΠΎΠ΄Π°Π²Π°ΠΉΡ‚Π΅ повідомлСння Π· Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΎΠΌ Ρƒ якості Ρ–ΠΌΠ΅Π½Ρ– стікСру Ρ‚Π° Π· Ρ‚ΠΈΠΌ самим Ρ‚Ρ€Ρ–ΠΏΠΊΠΎΠ΄ΠΎΠΌ. Π‡Ρ…Π½Ρ– вкладСння станут стікСрами."
640
641 msgid "Inappropriate sticker pack name."
642 msgstr "НСприпустимС Ρ–ΠΌ'я Π½Π°Π±ΠΎΡ€Ρƒ стікСрів."
@@ -1,171 +1,185 b''
1 1 from itertools import zip_longest
2 2
3 3 import boards
4 4 from boards.models import STATUS_ARCHIVE
5 5 from django.core.files.images import get_image_dimensions
6 6 from django.db import models
7 7
8 8 from boards import utils
9 9 from boards.models.attachment.viewers import get_viewers, AbstractViewer, \
10 10 FILE_TYPES_IMAGE
11 11 from boards.utils import get_upload_filename, get_extension, cached_result, \
12 12 get_file_mimetype
13 13
14 14
15 15 class AttachmentManager(models.Manager):
16 16 def create_with_hash(self, file):
17 17 file_hash = utils.get_file_hash(file)
18 18 attachment = self.get_existing_duplicate(file_hash, file)
19 19 if not attachment:
20 20 file_type = get_file_mimetype(file)
21 21 attachment = self.create(file=file, mimetype=file_type,
22 22 hash=file_hash)
23 23
24 24 return attachment
25 25
26 26 def create_from_url(self, url):
27 27 existing = self.filter(url=url)
28 28 if len(existing) > 0:
29 29 attachment = existing[0]
30 30 else:
31 31 attachment = self.create(url=url)
32 32 return attachment
33 33
34 34 def get_random_images(self, count, tags=None):
35 35 images = self.filter(mimetype__in=FILE_TYPES_IMAGE).exclude(
36 36 attachment_posts__thread__status=STATUS_ARCHIVE)
37 37 if tags is not None:
38 38 images = images.filter(attachment_posts__threads__tags__in=tags)
39 39 return images.order_by('?')[:count]
40 40
41 41 def get_existing_duplicate(self, file_hash, file):
42 42 """
43 43 Gets an attachment with the same file if one exists.
44 44 """
45 45 existing = self.filter(hash=file_hash)
46 46 attachment = None
47 47 for existing_attachment in existing:
48 48 existing_file = existing_attachment.file
49 49
50 50 file_chunks = file.chunks()
51 51 existing_file_chunks = existing_file.chunks()
52 52
53 53 if self._compare_chunks(file_chunks, existing_file_chunks):
54 54 attachment = existing_attachment
55 55 return attachment
56 56
57 57 def get_by_alias(self, name):
58 pack_name, sticker_name = name.split('/')
58 59 try:
59 return AttachmentSticker.objects.get(name=name).attachment
60 return AttachmentSticker.objects.get(name=sticker_name, stickerpack__name=pack_name).attachment
60 61 except AttachmentSticker.DoesNotExist:
61 62 return None
62 63
63 64 def _compare_chunks(self, chunks1, chunks2):
64 65 """
65 66 Compares 2 chunks of different sizes (e.g. first chunk array contains
66 67 all data in 1 chunk, and other one -- in a multiple of smaller ones.
67 68 """
68 69 equal = True
69 70
70 71 position1 = 0
71 72 position2 = 0
72 73 chunk1 = None
73 74 chunk2 = None
74 75 chunk1ended = False
75 76 chunk2ended = False
76 77 while True:
77 78 if not chunk1 or len(chunk1) <= position1:
78 79 try:
79 80 chunk1 = chunks1.__next__()
80 81 position1 = 0
81 82 except StopIteration:
82 83 chunk1ended = True
83 84 if not chunk2 or len(chunk2) <= position2:
84 85 try:
85 86 chunk2 = chunks2.__next__()
86 87 position2 = 0
87 88 except StopIteration:
88 89 chunk2ended = True
89 90
90 91 if chunk1ended and chunk2ended:
91 92 # Same size chunksm checked for equality previously
92 93 break
93 94 elif chunk1ended or chunk2ended:
94 95 # Different size chunks, not equal
95 96 equal = False
96 97 break
97 98 elif chunk1[position1] != chunk2[position2]:
98 99 # Different bytes, not equal
99 100 equal = False
100 101 break
101 102 else:
102 103 position1 += 1
103 104 position2 += 1
104 105 return equal
105 106
106 107
107 108 class Attachment(models.Model):
108 109 objects = AttachmentManager()
109 110
110 111 class Meta:
111 112 app_label = 'boards'
112 113 ordering = ('id',)
113 114
114 115 file = models.FileField(upload_to=get_upload_filename, null=True)
115 116 mimetype = models.CharField(max_length=200, null=True)
116 117 hash = models.CharField(max_length=36, null=True)
117 118 url = models.TextField(blank=True, default='')
118 119
119 120 def get_view(self):
120 121 file_viewer = None
121 122 for viewer in get_viewers():
122 123 if viewer.supports(self.mimetype):
123 124 file_viewer = viewer
124 125 break
125 126 if file_viewer is None:
126 127 file_viewer = AbstractViewer
127 128
128 129 return file_viewer(self.file, self.mimetype, self.id, self.url).get_view()
129 130
130 131 def __str__(self):
131 132 return self.url or self.file.url
132 133
133 134 def get_random_associated_post(self):
134 135 posts = boards.models.Post.objects.filter(attachments__in=[self])
135 136 return posts.order_by('?').first()
136 137
137 138 @cached_result()
138 139 def get_size(self):
139 140 if self.file:
140 141 if self.mimetype in FILE_TYPES_IMAGE:
141 142 return get_image_dimensions(self.file)
142 143 else:
143 144 return 200, 150
144 145
145 146 def get_thumb_url(self):
146 147 split = self.file.url.rsplit('.', 1)
147 148 w, h = 200, 150
148 149 return '%s.%sx%s.%s' % (split[0], w, h, split[1])
149 150
150 151 @cached_result()
151 152 def get_preview_size(self):
152 153 size = 200, 150
153 154 if self.mimetype in FILE_TYPES_IMAGE:
154 155 preview_path = self.file.path.replace('.', '.200x150.')
155 156 try:
156 157 size = get_image_dimensions(preview_path)
157 158 except Exception:
158 159 pass
159 160
160 161 return size
161 162
162 163 def is_internal(self):
163 164 return self.url is None or len(self.url) == 0
164 165
165 166
167 class StickerPack(models.Model):
168 name = models.TextField(unique=True)
169 tripcode = models.TextField(blank=True)
170
171 def __str__(self):
172 return self.name
173
174
166 175 class AttachmentSticker(models.Model):
167 176 attachment = models.ForeignKey('Attachment')
168 177 name = models.TextField(unique=True)
178 stickerpack = models.ForeignKey('StickerPack')
169 179
170 180 def __str__(self):
181 # Local stickers do not have a sticker pack
182 if hasattr(self, 'stickerpack'):
183 return '{}/{}'.format(str(self.stickerpack), self.name)
184 else:
171 185 return self.name
@@ -1,192 +1,228 b''
1 1 import logging
2
3 2 from datetime import datetime, timedelta, date
4 3 from datetime import time as dtime
5 4
6 from boards.abstracts.exceptions import BannedException, ArchiveException
5 from django.core.exceptions import PermissionDenied
7 6 from django.db import models, transaction
7 from django.dispatch import Signal
8 8 from django.utils import timezone
9 from django.dispatch import Signal
10 from django.core.exceptions import PermissionDenied
11 9
12 10 import boards
13
14 from boards.models.user import Ban
11 from boards import utils
12 from boards.abstracts.exceptions import ArchiveException
13 from boards.abstracts.constants import REGEX_TAGS
15 14 from boards.mdx_neboard import Parser
16 15 from boards.models import Attachment
17 from boards import utils
16 from boards.models.attachment import StickerPack, AttachmentSticker
17 from boards.models.user import Ban
18 18
19 19 __author__ = 'neko259'
20 20
21 21 POSTS_PER_DAY_RANGE = 7
22 22 NO_IP = '0.0.0.0'
23 23
24 24
25 25 post_import_deps = Signal()
26 26
27 27
28 28 class PostManager(models.Manager):
29 29 @transaction.atomic
30 30 def create_post(self, title: str, text: str, files=[], thread=None,
31 31 ip=NO_IP, tags: list=None,
32 32 tripcode='', monochrome=False, images=[],
33 file_urls=[]):
33 file_urls=[], stickerpack=False):
34 34 """
35 35 Creates new post
36 36 """
37 37
38 38 if thread is not None and thread.is_archived():
39 39 raise ArchiveException('Cannot post into an archived thread')
40 40
41 41 if not utils.is_anonymous_mode():
42 42 is_banned = Ban.objects.filter(ip=ip).exists()
43 43 else:
44 44 is_banned = False
45 45
46 46 if is_banned:
47 47 raise PermissionDenied()
48 48
49 49 if not tags:
50 50 tags = []
51 51
52 52 posting_time = timezone.now()
53 53 new_thread = False
54 54 if not thread:
55 55 thread = boards.models.thread.Thread.objects.create(
56 56 bump_time=posting_time, last_edit_time=posting_time,
57 monochrome=monochrome)
57 monochrome=monochrome, stickerpack=stickerpack)
58 58 list(map(thread.tags.add, tags))
59 59 new_thread = True
60 60
61 61 pre_text = Parser().preparse(text)
62 62
63 63 post = self.create(title=title,
64 64 text=pre_text,
65 65 pub_time=posting_time,
66 66 poster_ip=ip,
67 67 thread=thread,
68 68 last_edit_time=posting_time,
69 69 tripcode=tripcode,
70 70 opening=new_thread)
71 71
72 72 logger = logging.getLogger('boards.post.create')
73 73
74 74 logger.info('Created post [{}] with text [{}] by {}'.format(post,
75 75 post.get_text(),post.poster_ip))
76 76
77 77 for file in files:
78 78 self._add_file_to_post(file, post)
79 79 for image in images:
80 80 post.attachments.add(image)
81 81 for file_url in file_urls:
82 82 post.attachments.add(Attachment.objects.create_from_url(file_url))
83 83
84 84 post.set_global_id()
85 85
86 86 # Thread needs to be bumped only when the post is already created
87 87 if not new_thread:
88 88 thread.last_edit_time = posting_time
89 89 thread.bump()
90 90 thread.save()
91 91
92 self._create_stickers(post)
93
92 94 return post
93 95
94 96 def delete_posts_by_ip(self, ip):
95 97 """
96 98 Deletes all posts of the author with same IP
97 99 """
98 100
99 101 posts = self.filter(poster_ip=ip)
100 102 for post in posts:
101 103 post.delete()
102 104
103 105 @utils.cached_result()
104 106 def get_posts_per_day(self) -> float:
105 107 """
106 108 Gets average count of posts per day for the last 7 days
107 109 """
108 110
109 111 day_end = date.today()
110 112 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
111 113
112 114 day_time_start = timezone.make_aware(datetime.combine(
113 115 day_start, dtime()), timezone.get_current_timezone())
114 116 day_time_end = timezone.make_aware(datetime.combine(
115 117 day_end, dtime()), timezone.get_current_timezone())
116 118
117 119 posts_per_period = float(self.filter(
118 120 pub_time__lte=day_time_end,
119 121 pub_time__gte=day_time_start).count())
120 122
121 123 ppd = posts_per_period / POSTS_PER_DAY_RANGE
122 124
123 125 return ppd
124 126
125 127 def get_post_per_days(self, days) -> int:
126 128 day_end = date.today() + timedelta(1)
127 129 day_start = day_end - timedelta(days)
128 130
129 131 day_time_start = timezone.make_aware(datetime.combine(
130 132 day_start, dtime()), timezone.get_current_timezone())
131 133 day_time_end = timezone.make_aware(datetime.combine(
132 134 day_end, dtime()), timezone.get_current_timezone())
133 135
134 136 return self.filter(
135 137 pub_time__lte=day_time_end,
136 138 pub_time__gte=day_time_start).count()
137 139
138 140 @transaction.atomic
139 141 def import_post(self, title: str, text: str, pub_time: str, global_id,
140 142 opening_post=None, tags=list(), files=list(),
141 143 file_urls=list(), tripcode=None, last_edit_time=None):
142 144 is_opening = opening_post is None
143 145 if is_opening:
144 146 thread = boards.models.thread.Thread.objects.create(
145 147 bump_time=pub_time, last_edit_time=pub_time)
146 148 list(map(thread.tags.add, tags))
147 149 else:
148 150 thread = opening_post.get_thread()
149 151
150 152 post = self.create(title=title,
151 153 text=text,
152 154 pub_time=pub_time,
153 155 poster_ip=NO_IP,
154 156 last_edit_time=last_edit_time or pub_time,
155 157 global_id=global_id,
156 158 opening=is_opening,
157 159 thread=thread,
158 160 tripcode=tripcode)
159 161
160 162 for file in files:
161 163 self._add_file_to_post(file, post)
162 164 for file_url in file_urls:
163 165 post.attachments.add(Attachment.objects.create_from_url(file_url))
164 166
165 167 url_to_post = '[post]{}[/post]'.format(str(global_id))
166 168 replies = self.filter(text__contains=url_to_post)
167 169 for reply in replies:
168 170 post_import_deps.send(reply)
169 171
170 172 @transaction.atomic
171 173 def update_post(self, post, title: str, text: str, pub_time: str,
172 174 tags=list(), files=list(), file_urls=list(), tripcode=None):
173 175 post.title = title
174 176 post.text = text
175 177 post.pub_time = pub_time
176 178 post.tripcode = tripcode
177 179 post.save()
178 180
179 181 post.clear_cache()
180 182
181 183 post.attachments.clear()
182 184 for file in files:
183 185 self._add_file_to_post(file, post)
184 186 for file_url in file_urls:
185 187 post.attachments.add(Attachment.objects.create_from_url(file_url))
186 188
187 189 thread = post.get_thread()
188 190 thread.tags.clear()
189 191 list(map(thread.tags.add, tags))
190 192
191 193 def _add_file_to_post(self, file, post):
192 194 post.attachments.add(Attachment.objects.create_with_hash(file))
195
196 def _create_stickers(self, post):
197 thread = post.get_thread()
198 stickerpack_thread = thread.is_stickerpack()
199 if stickerpack_thread:
200 logger = logging.getLogger('boards.stickers')
201 if not post.is_opening():
202 has_title = len(post.title) > 0
203 has_one_attachment = post.attachments.count() == 1
204 opening_post = thread.get_opening_post()
205 valid_name = REGEX_TAGS.match(post.title)
206 if has_title and has_one_attachment and valid_name:
207 existing_sticker = AttachmentSticker.objects.filter(
208 name=post.get_title()).first()
209 attachment = post.attachments.first()
210 if existing_sticker:
211 existing_sticker.attachment = attachment
212 existing_sticker.save()
213 logger.info('Updated sticker {} with new attachment'.format(existing_sticker))
214 else:
215 try:
216 stickerpack = StickerPack.objects.get(
217 name=opening_post.get_title(), tripcode=post.tripcode)
218 sticker = AttachmentSticker.objects.create(
219 stickerpack=stickerpack, name=post.get_title(),
220 attachment=attachment)
221 logger.info('Created sticker {}'.format(sticker))
222 except StickerPack.DoesNotExist:
223 pass
224 else:
225 stickerpack, created = StickerPack.objects.get_or_create(
226 name=post.get_title(), tripcode=post.tripcode)
227 if created:
228 logger.info('Created stickerpack {}'.format(stickerpack))
@@ -1,313 +1,317 b''
1 1 import logging
2 2 from datetime import timedelta
3 3
4 4 from django.db import models, transaction
5 5 from django.db.models import Count, Sum, QuerySet, Q
6 6 from django.utils import timezone
7 7
8 8 import boards
9 9 from boards import settings
10 10 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
11 11 from boards.models.attachment import FILE_TYPES_IMAGE
12 12 from boards.models.post import Post
13 13 from boards.models.tag import Tag, DEFAULT_LOCALE, TagAlias
14 14 from boards.utils import cached_result, datetime_to_epoch
15 15
16 16 FAV_THREAD_NO_UPDATES = -1
17 17
18 18
19 19 __author__ = 'neko259'
20 20
21 21
22 22 logger = logging.getLogger(__name__)
23 23
24 24
25 25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 26 WS_NOTIFICATION_TYPE = 'notification_type'
27 27
28 28 WS_CHANNEL_THREAD = "thread:"
29 29
30 30 STATUS_CHOICES = (
31 31 (STATUS_ACTIVE, STATUS_ACTIVE),
32 32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
33 33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
34 34 )
35 35
36 36
37 37 class ThreadManager(models.Manager):
38 38 def process_old_threads(self):
39 39 """
40 40 Preserves maximum thread count. If there are too many threads,
41 41 archive or delete the old ones.
42 42 """
43 43 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
44 44 old_time = timezone.now() - timedelta(days=old_time_delta)
45 45 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
46 46
47 47 for op in old_ops:
48 48 thread = op.get_thread()
49 49 if settings.get_bool('Storage', 'ArchiveThreads'):
50 50 self._archive_thread(thread)
51 51 else:
52 52 thread.delete()
53 53 logger.info('Processed old thread {}'.format(thread))
54 54
55 55
56 56 def _archive_thread(self, thread):
57 57 thread.status = STATUS_ARCHIVE
58 58 thread.last_edit_time = timezone.now()
59 59 thread.update_posts_time()
60 60 thread.save(update_fields=['last_edit_time', 'status'])
61 61
62 62 def get_new_posts(self, datas):
63 63 query = None
64 64 # TODO Use classes instead of dicts
65 65 for data in datas:
66 66 if data['last_id'] != FAV_THREAD_NO_UPDATES:
67 67 q = (Q(id=data['op'].get_thread_id())
68 68 & Q(replies__id__gt=data['last_id']))
69 69 if query is None:
70 70 query = q
71 71 else:
72 72 query = query | q
73 73 if query is not None:
74 74 return self.filter(query).annotate(
75 75 new_post_count=Count('replies'))
76 76
77 77 def get_new_post_count(self, datas):
78 78 new_posts = self.get_new_posts(datas)
79 79 return new_posts.aggregate(total_count=Count('replies'))\
80 80 ['total_count'] if new_posts else 0
81 81
82 82
83 83 def get_thread_max_posts():
84 84 return settings.get_int('Messages', 'MaxPostsPerThread')
85 85
86 86
87 87 class Thread(models.Model):
88 88 objects = ThreadManager()
89 89
90 90 class Meta:
91 91 app_label = 'boards'
92 92
93 93 tags = models.ManyToManyField('Tag', related_name='thread_tags')
94 94 bump_time = models.DateTimeField(db_index=True)
95 95 last_edit_time = models.DateTimeField()
96 96 max_posts = models.IntegerField(default=get_thread_max_posts)
97 97 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
98 98 choices=STATUS_CHOICES, db_index=True)
99 99 monochrome = models.BooleanField(default=False)
100 stickerpack = models.BooleanField(default=False)
100 101
101 102 def get_tags(self) -> QuerySet:
102 103 """
103 104 Gets a sorted tag list.
104 105 """
105 106
106 107 return self.tags.filter(aliases__in=TagAlias.objects.filter_localized(parent__thread_tags=self)).order_by('aliases__name')
107 108
108 109 def bump(self):
109 110 """
110 111 Bumps (moves to up) thread if possible.
111 112 """
112 113
113 114 if self.can_bump():
114 115 self.bump_time = self.last_edit_time
115 116
116 117 self.update_bump_status()
117 118
118 119 logger.info('Bumped thread %d' % self.id)
119 120
120 121 def has_post_limit(self) -> bool:
121 122 return self.max_posts > 0
122 123
123 124 def update_bump_status(self, exclude_posts=None):
124 125 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
125 126 self.status = STATUS_BUMPLIMIT
126 127 self.update_posts_time(exclude_posts=exclude_posts)
127 128
128 129 def _get_cache_key(self):
129 130 return [datetime_to_epoch(self.last_edit_time)]
130 131
131 132 @cached_result(key_method=_get_cache_key)
132 133 def get_reply_count(self) -> int:
133 134 return self.get_replies().count()
134 135
135 136 @cached_result(key_method=_get_cache_key)
136 137 def get_images_count(self) -> int:
137 138 return self.get_replies().filter(
138 139 attachments__mimetype__in=FILE_TYPES_IMAGE)\
139 140 .annotate(images_count=Count(
140 141 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
141 142
142 143 def can_bump(self) -> bool:
143 144 """
144 145 Checks if the thread can be bumped by replying to it.
145 146 """
146 147
147 148 return self.get_status() == STATUS_ACTIVE
148 149
149 150 def get_last_replies(self) -> QuerySet:
150 151 """
151 152 Gets several last replies, not including opening post
152 153 """
153 154
154 155 last_replies_count = settings.get_int('View', 'LastRepliesCount')
155 156
156 157 if last_replies_count > 0:
157 158 reply_count = self.get_reply_count()
158 159
159 160 if reply_count > 0:
160 161 reply_count_to_show = min(last_replies_count,
161 162 reply_count - 1)
162 163 replies = self.get_replies()
163 164 last_replies = replies[reply_count - reply_count_to_show:]
164 165
165 166 return last_replies
166 167
167 168 def get_skipped_replies_count(self) -> int:
168 169 """
169 170 Gets number of posts between opening post and last replies.
170 171 """
171 172 reply_count = self.get_reply_count()
172 173 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
173 174 reply_count - 1)
174 175 return reply_count - last_replies_count - 1
175 176
176 177 # TODO Remove argument, it is not used
177 178 def get_replies(self, view_fields_only=True) -> QuerySet:
178 179 """
179 180 Gets sorted thread posts
180 181 """
181 182 query = self.replies.order_by('pub_time').prefetch_related(
182 183 'attachments')
183 184 return query
184 185
185 186 def get_viewable_replies(self) -> QuerySet:
186 187 """
187 188 Gets replies with only fields that are used for viewing.
188 189 """
189 190 return self.get_replies().defer('text', 'last_edit_time')
190 191
191 192 def get_top_level_replies(self) -> QuerySet:
192 193 return self.get_replies().exclude(refposts__threads__in=[self])
193 194
194 195 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
195 196 """
196 197 Gets replies that have at least one image attached
197 198 """
198 199 return self.get_replies(view_fields_only).filter(
199 200 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
200 201 'attachments')).filter(images_count__gt=0)
201 202
202 203 def get_opening_post(self, only_id=False) -> Post:
203 204 """
204 205 Gets the first post of the thread
205 206 """
206 207
207 208 query = self.get_replies().filter(opening=True)
208 209 if only_id:
209 210 query = query.only('id')
210 211 opening_post = query.first()
211 212
212 213 return opening_post
213 214
214 215 @cached_result()
215 216 def get_opening_post_id(self) -> int:
216 217 """
217 218 Gets ID of the first thread post.
218 219 """
219 220
220 221 return self.get_opening_post(only_id=True).id
221 222
222 223 def get_pub_time(self):
223 224 """
224 225 Gets opening post's pub time because thread does not have its own one.
225 226 """
226 227
227 228 return self.get_opening_post().pub_time
228 229
229 230 def __str__(self):
230 231 return 'T#{}'.format(self.id)
231 232
232 233 def get_tag_url_list(self) -> list:
233 234 return boards.models.Tag.objects.get_tag_url_list(self.get_tags().all())
234 235
235 236 def update_posts_time(self, exclude_posts=None):
236 237 last_edit_time = self.last_edit_time
237 238
238 239 for post in self.replies.all():
239 240 if exclude_posts is None or post not in exclude_posts:
240 241 # Manual update is required because uids are generated on save
241 242 post.last_edit_time = last_edit_time
242 243 post.save(update_fields=['last_edit_time'])
243 244
244 245 def get_absolute_url(self):
245 246 return self.get_opening_post().get_absolute_url()
246 247
247 248 def get_required_tags(self):
248 249 return self.get_tags().filter(required=True)
249 250
250 251 def get_sections_str(self):
251 252 return Tag.objects.get_tag_url_list(self.get_required_tags())
252 253
253 254 def get_replies_newer(self, post_id):
254 255 return self.get_replies().filter(id__gt=post_id)
255 256
256 257 def is_archived(self):
257 258 return self.get_status() == STATUS_ARCHIVE
258 259
259 260 def get_status(self):
260 261 return self.status
261 262
262 263 def is_monochrome(self):
263 264 return self.monochrome
264 265
266 def is_stickerpack(self):
267 return self.stickerpack
268
265 269 # If tags have parent, add them to the tag list
266 270 @transaction.atomic
267 271 def refresh_tags(self):
268 272 for tag in self.get_tags().all():
269 273 parents = tag.get_all_parents()
270 274 if len(parents) > 0:
271 275 self.tags.add(*parents)
272 276
273 277 def get_reply_tree(self):
274 278 replies = self.get_replies().prefetch_related('refposts')
275 279 tree = []
276 280 for reply in replies:
277 281 parents = reply.refposts.all()
278 282
279 283 found_parent = False
280 284 searching_for_index = False
281 285
282 286 if len(parents) > 0:
283 287 index = 0
284 288 parent_depth = 0
285 289
286 290 indexes_to_insert = []
287 291
288 292 for depth, element in tree:
289 293 index += 1
290 294
291 295 # If this element is next after parent on the same level,
292 296 # insert child before it
293 297 if searching_for_index and depth <= parent_depth:
294 298 indexes_to_insert.append((index - 1, parent_depth))
295 299 searching_for_index = False
296 300
297 301 if element in parents:
298 302 found_parent = True
299 303 searching_for_index = True
300 304 parent_depth = depth
301 305
302 306 if not found_parent:
303 307 tree.append((0, reply))
304 308 else:
305 309 if searching_for_index:
306 310 tree.append((parent_depth + 1, reply))
307 311
308 312 offset = 0
309 313 for last_index, parent_depth in indexes_to_insert:
310 314 tree.insert(last_index + offset, (parent_depth + 1, reply))
311 315 offset += 1
312 316
313 317 return tree
@@ -1,141 +1,143 b''
1 1 import re
2 2 import os
3 import logging
3 4
4 5 from django.db.models.signals import post_save, pre_save, pre_delete, \
5 6 post_delete
6 7 from django.dispatch import receiver
7 8 from django.utils import timezone
8 9
9 10 from boards import thumbs
10 11 from boards.mdx_neboard import get_parser
11 12
12 from boards.models import Post, GlobalId, Attachment
13 from boards.models import Post, GlobalId, Attachment, Thread
14 from boards.models.attachment import StickerPack, AttachmentSticker
13 15 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
14 16 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
15 17 REGEX_GLOBAL_REPLY
16 18 from boards.models.post.manager import post_import_deps
17 19 from boards.models.user import Notification
18 20 from neboard.settings import MEDIA_ROOT
19 21
20 22
21 23 THUMB_SIZES = ((200, 150),)
22 24
23 25
24 26 @receiver(post_save, sender=Post)
25 27 def connect_replies(instance, **kwargs):
26 28 if not kwargs['update_fields']:
27 29 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
28 30 post_id = reply_number.group(1)
29 31
30 32 try:
31 33 referenced_post = Post.objects.get(id=post_id)
32 34
33 35 if not referenced_post.referenced_posts.filter(
34 36 id=instance.id).exists():
35 37 referenced_post.referenced_posts.add(instance)
36 38 referenced_post.last_edit_time = instance.pub_time
37 39 referenced_post.build_refmap()
38 40 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
39 41 except Post.DoesNotExist:
40 42 pass
41 43
42 44
43 45 @receiver(post_save, sender=Post)
44 46 @receiver(post_import_deps, sender=Post)
45 47 def connect_global_replies(instance, **kwargs):
46 48 if not kwargs['update_fields']:
47 49 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
48 50 key_type = reply_number.group(1)
49 51 key = reply_number.group(2)
50 52 local_id = reply_number.group(3)
51 53
52 54 try:
53 55 global_id = GlobalId.objects.get(key_type=key_type, key=key,
54 56 local_id=local_id)
55 57 referenced_post = Post.objects.get(global_id=global_id)
56 58 referenced_post.referenced_posts.add(instance)
57 59 referenced_post.last_edit_time = instance.pub_time
58 60 referenced_post.build_refmap()
59 61 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
60 62 except (GlobalId.DoesNotExist, Post.DoesNotExist):
61 63 pass
62 64
63 65
64 66 @receiver(post_save, sender=Post)
65 67 def connect_notifications(instance, **kwargs):
66 68 if not kwargs['update_fields']:
67 69 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
68 70 user_name = reply_number.group(1).lower()
69 71 Notification.objects.get_or_create(name=user_name, post=instance)
70 72
71 73
72 74 @receiver(pre_save, sender=Post)
73 75 @receiver(post_import_deps, sender=Post)
74 76 def parse_text(instance, **kwargs):
75 77 instance._text_rendered = get_parser().parse(instance.get_raw_text())
76 78
77 79
78 80 @receiver(pre_delete, sender=Post)
79 81 def delete_attachments(instance, **kwargs):
80 82 for attachment in instance.attachments.all():
81 83 attachment_refs_count = attachment.attachment_posts.count()
82 84 if attachment_refs_count == 1:
83 85 attachment.delete()
84 86
85 87
86 88 @receiver(post_delete, sender=Post)
87 89 def update_thread_on_delete(instance, **kwargs):
88 90 thread = instance.get_thread()
89 91 thread.last_edit_time = timezone.now()
90 92 thread.save()
91 93
92 94
93 95 @receiver(post_delete, sender=Post)
94 96 def delete_global_id(instance, **kwargs):
95 97 if instance.global_id and instance.global_id.id:
96 98 instance.global_id.delete()
97 99
98 100
99 101 @receiver(post_save, sender=Attachment)
100 102 def generate_thumb(instance, **kwargs):
101 103 if instance.mimetype in FILE_TYPES_IMAGE:
102 104 for size in THUMB_SIZES:
103 105 (w, h) = size
104 106 split = instance.file.name.rsplit('.', 1)
105 107 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
106 108
107 109 if not instance.file.storage.exists(thumb_name):
108 110 # you can use another thumbnailing function if you like
109 111 thumb_content = thumbs.generate_thumb(instance.file, size, split[1])
110 112
111 113 thumb_name_ = instance.file.storage.save(thumb_name, thumb_content)
112 114
113 115 if not thumb_name == thumb_name_:
114 116 raise ValueError(
115 117 'There is already a file named %s' % thumb_name_)
116 118
117 119
118 120 @receiver(pre_delete, sender=Post)
119 121 def rebuild_refmap(instance, **kwargs):
120 122 for referenced_post in instance.refposts.all():
121 123 referenced_post.build_refmap(excluded_ids=[instance.id])
122 124 referenced_post.save(update_fields=['refmap'])
123 125
124 126
125 127 @receiver(post_delete, sender=Attachment)
126 128 def delete_file(instance, **kwargs):
127 129 if instance.is_internal():
128 130 file = MEDIA_ROOT + instance.file.name
129 131 try:
130 132 os.remove(file)
131 133 except FileNotFoundError:
132 134 pass
133 135 if instance.mimetype in FILE_TYPES_IMAGE:
134 136 for size in THUMB_SIZES:
135 137 file_name_parts = instance.file.name.split('.')
136 138 thumb_file = MEDIA_ROOT + '{}.{}x{}.{}'.format(file_name_parts[0], size[0], size[1], file_name_parts[1])
137 139 try:
138 140 os.remove(thumb_file)
139 141 except FileNotFoundError:
140 142 pass
141 143
@@ -1,33 +1,33 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load tz %}
5 5
6 6 {% block head %}
7 7 <meta name="robots" content="noindex">
8 8 <title>{% trans "Stickers" %} - {{ site_name }}</title>
9 9 {% endblock %}
10 10
11 11 {% block content %}
12 12 <div id="posts-table">
13 13 {% if local_stickers %}
14 14 <h1>{% trans "Local stickers" %}</h1>
15 15 {% for sticker in local_stickers %}
16 16 <div class="gallery_image">
17 17 {{ sticker.attachment.get_view|safe }}
18 <div>{{ sticker.name }}</div>
18 <div>{{ sticker }}</div>
19 19 <div><a href="?action=remove&name={{ sticker.name }}">{% trans "Remove sticker" %}</a></div>
20 20 </div>
21 21 {% endfor %}
22 22 {% endif %}
23 23 {% if global_stickers %}
24 24 <h1>{% trans "Global stickers" %}</h1>
25 25 {% for sticker in global_stickers %}
26 26 <div class="gallery_image">
27 27 {{ sticker.attachment.get_view|safe }}
28 <div>{{ sticker.name }}</div>
28 <div>{{ sticker }}</div>
29 29 </div>
30 30 {% endfor %}
31 31 {% endif %}
32 32 </div>
33 33 {% endblock %}
@@ -1,161 +1,164 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}" {% if tree_depth %}style="margin-left: {{ tree_depth }}em;"{% endif %}>
7 7 <div class="post-info">
8 8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.id }}</a>
9 {% if is_opening and thread.is_stickerpack %}
10 &#128247;
11 {% endif %}
9 12 <span class="title">{{ post.title }}</span>
10 13 {% if perms.boards.change_post and post.has_ip %}
11 14 <span class="pub_time" style="border-bottom: solid 2px #{{ post.get_ip_color }};" title="{{ post.poster_ip }}">
12 15 {% else %}
13 16 <span class="pub_time">
14 17 {% endif %}
15 18 <time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
16 19 {% if post.tripcode %}
17 20 /
18 21 {% with tripcode=post.get_tripcode %}
19 22 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
20 23 class="tripcode" title="{{ tripcode.get_full_text }}"
21 24 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
22 25 {% endwith %}
23 26 {% endif %}
24 27 {% comment %}
25 28 Thread death time needs to be shown only if the thread is alredy archived
26 29 and this is an opening post (thread death time) or a post for popup
27 30 (we don't see OP here so we show the death time in the post itself).
28 31 {% endcomment %}
29 32 {% if is_opening and thread.is_archived %}
30 33 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
31 34 {% endif %}
32 35 {% if is_opening %}
33 36 {% if need_open_link %}
34 37 {% if thread.is_archived %}
35 38 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
36 39 {% else %}
37 40 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
38 41 {% endif %}
39 42 {% endif %}
40 43 {% else %}
41 44 {% if need_op_data %}
42 45 {% with thread.get_opening_post as op %}
43 46 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
44 47 {% endwith %}
45 48 {% endif %}
46 49 {% endif %}
47 50 {% if reply_link and not thread.is_archived %}
48 51 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
49 52 {% endif %}
50 53
51 54 {% if perms.boards.change_post or perms.boards.delete_post or perms.boards.change_thread or perms_boards.delete_thread %}
52 55 <a class="moderation-menu" href="#">πŸ”’</a>
53 56 <script>
54 57 $.contextMenu({
55 58 selector: '#{{ post.id }} .moderation-menu',
56 59 trigger: 'left',
57 60 build: function($trigger, e) {
58 61 return {
59 62 items: {
60 63 edit: {
61 64 name: '{% trans "Edit" %}',
62 65 callback: function(key, opt) {
63 66 window.location = '{% url 'admin:boards_post_change' post.id %}';
64 67 },
65 68 visible: {% if perms.boards.change_post %}true{% else %}false{% endif %}
66 69 },
67 70 deletePost: {
68 71 name: '{% trans "Delete post" %}',
69 72 callback: function(key, opt) {
70 73 window.location = '{% url 'admin:boards_post_delete' post.id %}';
71 74 },
72 75 visible: {% if not is_opening and perms.boards.delete_post %}true{% else %}false{% endif %}
73 76 },
74 77 editThread: {
75 78 name: '{% trans "Edit thread" %}',
76 79 callback: function(key, opt) {
77 80 window.location = '{% url 'admin:boards_thread_change' thread.id %}';
78 81 },
79 82 visible: {% if is_opening and perms.boards.change_thread %}true{% else %}false{% endif %}
80 83 },
81 84 deleteThread: {
82 85 name: '{% trans "Delete thread" %}',
83 86 callback: function(key, opt) {
84 87 window.location = '{% url 'admin:boards_thread_delete' thread.id %}';
85 88 },
86 89 visible: {% if is_opening and perms.boards.delete_thread %}true{% else %}false{% endif %}
87 90 },
88 91 findByIp: {
89 92 name: 'IP = {{ post.poster_ip }}',
90 93 callback: function(key, opt) {
91 94 window.location = '{% url "feed" %}?ip={{ post.poster_ip }}';
92 95 },
93 96 visible: {% if post.has_ip %}true{% else %}false{% endif %}
94 97 },
95 98 raw: {
96 99 name: 'RAW',
97 100 callback: function(key, opt) {
98 101 window.location = '{% url 'post_sync_data' post.id %}';
99 102 },
100 103 visible: {% if post.global_id_id %}true{% else %}false{% endif %}
101 104 },
102 105 ban: {
103 106 name: '{% trans "Ban" %}',
104 107 callback: function(key, opt) {
105 108 if (confirm('{% trans "Are you sure?" %}')) {
106 109 window.location = '{% url 'utils' %}?method=ban&post_id={{ post.id }}';
107 110 }
108 111 },
109 112 visible: {% if post.has_ip %}true{% else %}false{% endif %}
110 113 },
111 114 banAndDelete: {
112 115 name: '{% trans "Ban and delete" %}',
113 116 callback: function(key, opt) {
114 117 if (confirm('{% trans "Are you sure?" %}')) {
115 118 window.location = '{% url 'utils' %}?method=ban_and_delete&post_id={{ post.id }}';
116 119 }
117 120 },
118 121 visible: {% if post.has_ip %}true{% else %}false{% endif %}
119 122 }
120 123 }
121 124 };
122 125 }
123 126 });
124 127 </script>
125 128 {% endif %}
126 129 </div>
127 130 {% for file in post.attachments.all %}
128 131 {{ file.get_view|safe }}
129 132 {% endfor %}
130 133 {% comment %}
131 134 Post message (text)
132 135 {% endcomment %}
133 136 <div class="message">
134 137 {% if truncated %}
135 138 {{ post.get_text|truncatewords_html:50|truncatenewlines_html:3|safe }}
136 139 {% else %}
137 140 {{ post.get_text|safe }}
138 141 {% endif %}
139 142 </div>
140 143 {% if post.is_referenced and not mode_tree %}
141 144 <div class="refmap">
142 145 {% trans "Replies" %}: {{ post.refmap|safe }}
143 146 </div>
144 147 {% endif %}
145 148 {% comment %}
146 149 Thread metadata: counters, tags etc
147 150 {% endcomment %}
148 151 {% if is_opening %}
149 152 <div class="metadata">
150 153 {% if need_open_link %}
151 154 β™₯ {{ thread.get_reply_count }}
152 155 ❄ {{ thread.get_images_count }}
153 156 <a href="{% url 'thread_gallery' post.id %}">G</a>
154 157 <a href="{% url 'thread_tree' post.id %}">T</a>
155 158 {% endif %}
156 159 <span class="tags">
157 160 {{ thread.get_tag_url_list|safe }}
158 161 </span>
159 162 </div>
160 163 {% endif %}
161 164 </div>
@@ -1,24 +1,25 b''
1 1 {% extends "boards/static_base.html" %}
2 2
3 3 {% load i18n %}
4 4
5 5 {% block head %}
6 6 <title>{% trans "Syntax" %}</title>
7 7 {% endblock %}
8 8
9 9 {% block staticcontent %}
10 10 <h2>{% trans 'Help' %}</h2>
11 11 <p>{% trans 'View available stickers:' %} <a href="{% url 'stickers' %}">{% trans 'Stickers' %}</a></p>
12 <p>{% trans 'To add a sticker, create a stickerpack thread using the title as a pack name, and a tripcode to own the pack. Then, add posts with title as a sticker name, and the same tripcode, to the thread. Their attachments would become stickers.' %}</p>
12 13 <hr />
13 14 <p>[i]<i>{% trans 'Italic text' %}</i>[/i]</p>
14 15 <p>[b]<b>{% trans 'Bold text' %}</b>[/b]</p>
15 16 <p>[spoiler]<span class="spoiler">{% trans 'Spoiler' %}</span>[/spoiler]</p>
16 17 <p>[post]123[/post] β€” {% trans 'Link to a post' %}</p>
17 18 <p>[s]<span class="strikethrough">{% trans 'Strikethrough text' %}</span>[/s]</p>
18 19 <p>[comment]<span class="comment">{% trans 'Comment' %}</span>[/comment]</p>
19 20 <p>[quote]<span class="quote">&gt;{% trans 'Quote' %}</span>[/quote]</p>
20 21 <p>[quote=src]<div class="multiquote"><div class="quote-header">src</div><div class="quote-text">{% trans 'Quote' %}</div></div><br />[/quote]</p>
21 22 <p>[tag]<a class="tag">tag</a>[/tag]</p>
22 23 <hr/>
23 24 <p>{% trans 'You can try pasting the text and previewing the result here:' %} <a href="{% url 'preview' %}">{% trans 'Preview' %}</a></p>
24 25 {% endblock %}
@@ -1,179 +1,180 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.core.files import File
3 3 from django.core.files.temp import NamedTemporaryFile
4 4 from django.core.paginator import EmptyPage
5 5 from django.db import transaction
6 6 from django.http import Http404
7 7 from django.shortcuts import render, redirect
8 8 from django.utils.decorators import method_decorator
9 9 from django.views.decorators.csrf import csrf_protect
10 10
11 11 from boards import utils, settings
12 12 from boards.abstracts.paginator import get_paginator
13 13 from boards.abstracts.settingsmanager import get_settings_manager,\
14 14 SETTING_ONLY_FAVORITES
15 15 from boards.forms import ThreadForm, PlainErrorList
16 16 from boards.models import Post, Thread, Ban
17 17 from boards.views.banned import BannedView
18 18 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 19 from boards.views.posting_mixin import PostMixin
20 20 from boards.views.mixins import FileUploadMixin, PaginatedMixin,\
21 21 DispatcherMixin, PARAMETER_METHOD
22 22
23 23 FORM_TAGS = 'tags'
24 24 FORM_TEXT = 'text'
25 25 FORM_TITLE = 'title'
26 26 FORM_IMAGE = 'image'
27 27 FORM_THREADS = 'threads'
28 28
29 29 TAG_DELIMITER = ' '
30 30
31 31 PARAMETER_CURRENT_PAGE = 'current_page'
32 32 PARAMETER_PAGINATOR = 'paginator'
33 33 PARAMETER_THREADS = 'threads'
34 34 PARAMETER_ADDITIONAL = 'additional_params'
35 35 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
36 36 PARAMETER_RSS_URL = 'rss_url'
37 37 PARAMETER_MAX_FILES = 'max_files'
38 38
39 39 TEMPLATE = 'boards/all_threads.html'
40 40 DEFAULT_PAGE = 1
41 41
42 42 FORM_TAGS = 'tags'
43 43
44 44
45 45 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin, DispatcherMixin):
46 46
47 47 tag_name = ''
48 48
49 49 def __init__(self):
50 50 self.settings_manager = None
51 51 super(AllThreadsView, self).__init__()
52 52
53 53 @method_decorator(csrf_protect)
54 54 def get(self, request, form: ThreadForm=None):
55 55 page = request.GET.get('page', DEFAULT_PAGE)
56 56
57 57 params = self.get_context_data(request=request)
58 58
59 59 if not form:
60 60 form = ThreadForm(error_class=PlainErrorList,
61 61 initial={FORM_TAGS: self.tag_name})
62 62
63 63 self.settings_manager = get_settings_manager(request)
64 64
65 65 threads = self.get_threads()
66 66
67 67 order = request.GET.get('order', 'bump')
68 68 if order == 'bump':
69 69 threads = threads.order_by('-bump_time')
70 70 else:
71 71 threads = threads.filter(replies__opening=True)\
72 72 .order_by('-replies__pub_time')
73 73 filter = request.GET.get('filter')
74 74 threads = threads.distinct()
75 75
76 76 paginator = get_paginator(threads,
77 77 settings.get_int('View', 'ThreadsPerPage'))
78 78 paginator.current_page = int(page)
79 79
80 80 try:
81 81 threads = paginator.page(page).object_list
82 82 except EmptyPage:
83 83 raise Http404()
84 84
85 85 params[PARAMETER_THREADS] = threads
86 86 params[CONTEXT_FORM] = form
87 87 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
88 88 params[PARAMETER_RSS_URL] = self.get_rss_url()
89 89 params[PARAMETER_MAX_FILES] = settings.get_int('Forms', 'MaxFileCount')
90 90
91 91 paginator.set_url(self.get_reverse_url(), request.GET.dict())
92 92 params.update(self.get_page_context(paginator, page))
93 93
94 94 return render(request, TEMPLATE, params)
95 95
96 96 @method_decorator(csrf_protect)
97 97 def post(self, request):
98 98 if PARAMETER_METHOD in request.POST:
99 99 self.dispatch_method(request)
100 100
101 101 return redirect('index') # FIXME Different for different modes
102 102
103 103 form = ThreadForm(request.POST, request.FILES,
104 104 error_class=PlainErrorList)
105 105 form.session = request.session
106 106
107 107 if form.is_valid():
108 108 return self.create_thread(request, form)
109 109 if form.need_to_ban:
110 110 # Ban user because he is suspected to be a bot
111 111 self._ban_current_user(request)
112 112
113 113 return self.get(request, form)
114 114
115 115 def get_reverse_url(self):
116 116 return reverse('index')
117 117
118 118 @transaction.atomic
119 119 def create_thread(self, request, form: ThreadForm, html_response=True):
120 120 """
121 121 Creates a new thread with an opening post.
122 122 """
123 123
124 124 ip = utils.get_client_ip(request)
125 125 is_banned = Ban.objects.filter(ip=ip).exists()
126 126
127 127 if is_banned:
128 128 if html_response:
129 129 return redirect(BannedView().as_view())
130 130 else:
131 131 return
132 132
133 133 data = form.cleaned_data
134 134
135 135 title = form.get_title()
136 136 text = data[FORM_TEXT]
137 137 files = form.get_files()
138 138 file_urls = form.get_file_urls()
139 139 images = form.get_images()
140 140
141 141 text = self._remove_invalid_links(text)
142 142
143 143 tags = data[FORM_TAGS]
144 144 monochrome = form.is_monochrome()
145 stickerpack = form.is_stickerpack()
145 146
146 147 post = Post.objects.create_post(title=title, text=text, files=files,
147 148 ip=ip, tags=tags,
148 149 tripcode=form.get_tripcode(),
149 150 monochrome=monochrome, images=images,
150 file_urls=file_urls)
151 file_urls=file_urls, stickerpack=stickerpack)
151 152
152 153 if form.is_subscribe():
153 154 settings_manager = get_settings_manager(request)
154 155 settings_manager.add_or_read_fav_thread(post)
155 156
156 157 if html_response:
157 158 return redirect(post.get_absolute_url())
158 159
159 160 def get_threads(self):
160 161 """
161 162 Gets list of threads that will be shown on a page.
162 163 """
163 164
164 165 threads = Thread.objects\
165 166 .exclude(tags__in=self.settings_manager.get_hidden_tags())
166 167 if self.settings_manager.get_setting(SETTING_ONLY_FAVORITES):
167 168 fav_tags = self.settings_manager.get_fav_tags()
168 169 if len(fav_tags) > 0:
169 170 threads = threads.filter(tags__in=fav_tags)
170 171
171 172 return threads
172 173
173 174 def get_rss_url(self):
174 175 return self.get_reverse_url() + 'rss/'
175 176
176 177 def toggle_fav(self, request):
177 178 settings_manager = get_settings_manager(request)
178 179 settings_manager.set_setting(SETTING_ONLY_FAVORITES,
179 180 not settings_manager.get_setting(SETTING_ONLY_FAVORITES, False))
@@ -1,316 +1,317 b''
1 1 import json
2 2 import logging
3 3
4 4 from django.core import serializers
5 5 from django.db import transaction
6 from django.db.models import Q
6 7 from django.http import HttpResponse, HttpResponseBadRequest
7 8 from django.shortcuts import get_object_or_404
8 9 from django.views.decorators.csrf import csrf_protect
9 10
10 11 from boards.abstracts.settingsmanager import get_settings_manager
11 12 from boards.forms import PostForm, PlainErrorList
12 13 from boards.mdx_neboard import Parser
13 14 from boards.models import Post, Thread, Tag, TagAlias
14 15 from boards.models.attachment import AttachmentSticker
15 16 from boards.models.thread import STATUS_ARCHIVE
16 17 from boards.models.user import Notification
17 18 from boards.utils import datetime_to_epoch
18 19 from boards.views.thread import ThreadView
19 20
20 21 __author__ = 'neko259'
21 22
22 23 PARAMETER_TRUNCATED = 'truncated'
23 24 PARAMETER_TAG = 'tag'
24 25 PARAMETER_OFFSET = 'offset'
25 26 PARAMETER_DIFF_TYPE = 'type'
26 27 PARAMETER_POST = 'post'
27 28 PARAMETER_UPDATED = 'updated'
28 29 PARAMETER_LAST_UPDATE = 'last_update'
29 30 PARAMETER_THREAD = 'thread'
30 31 PARAMETER_UIDS = 'uids'
31 32 PARAMETER_SUBSCRIBED = 'subscribed'
32 33
33 34 DIFF_TYPE_HTML = 'html'
34 35 DIFF_TYPE_JSON = 'json'
35 36
36 37 STATUS_OK = 'ok'
37 38 STATUS_ERROR = 'error'
38 39
39 40 logger = logging.getLogger(__name__)
40 41
41 42
42 43 @transaction.atomic
43 44 def api_get_threaddiff(request):
44 45 """
45 46 Gets posts that were changed or added since time
46 47 """
47 48
48 49 thread_id = request.POST.get(PARAMETER_THREAD)
49 50 uids_str = request.POST.get(PARAMETER_UIDS)
50 51
51 52 if not thread_id or not uids_str:
52 53 return HttpResponse(content='Invalid request.')
53 54
54 55 uids = uids_str.strip().split(' ')
55 56
56 57 opening_post = get_object_or_404(Post, id=thread_id)
57 58 thread = opening_post.get_thread()
58 59
59 60 json_data = {
60 61 PARAMETER_UPDATED: [],
61 62 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
62 63 }
63 64 posts = Post.objects.filter(thread=thread).exclude(uid__in=uids)
64 65
65 66 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
66 67
67 68 for post in posts:
68 69 json_data[PARAMETER_UPDATED].append(post.get_post_data(
69 70 format_type=diff_type, request=request))
70 71 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
71 72
72 73 settings_manager = get_settings_manager(request)
73 74 json_data[PARAMETER_SUBSCRIBED] = str(settings_manager.thread_is_fav(opening_post))
74 75
75 76 # If the tag is favorite, update the counter
76 77 settings_manager = get_settings_manager(request)
77 78 favorite = settings_manager.thread_is_fav(opening_post)
78 79 if favorite:
79 80 settings_manager.add_or_read_fav_thread(opening_post)
80 81
81 82 return HttpResponse(content=json.dumps(json_data))
82 83
83 84
84 85 @csrf_protect
85 86 def api_add_post(request, opening_post_id):
86 87 """
87 88 Adds a post and return the JSON response for it
88 89 """
89 90
90 91 opening_post = get_object_or_404(Post, id=opening_post_id)
91 92
92 93 logger.info('Adding post via api...')
93 94
94 95 status = STATUS_OK
95 96 errors = []
96 97
97 98 if request.method == 'POST':
98 99 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
99 100 form.session = request.session
100 101
101 102 if form.need_to_ban:
102 103 # Ban user because he is suspected to be a bot
103 104 # _ban_current_user(request)
104 105 status = STATUS_ERROR
105 106 if form.is_valid():
106 107 post = ThreadView().new_post(request, form, opening_post,
107 108 html_response=False)
108 109 if not post:
109 110 status = STATUS_ERROR
110 111 else:
111 112 logger.info('Added post #%d via api.' % post.id)
112 113 else:
113 114 status = STATUS_ERROR
114 115 errors = form.as_json_errors()
115 116
116 117 response = {
117 118 'status': status,
118 119 'errors': errors,
119 120 }
120 121
121 122 return HttpResponse(content=json.dumps(response))
122 123
123 124
124 125 def get_post(request, post_id):
125 126 """
126 127 Gets the html of a post. Used for popups. Post can be truncated if used
127 128 in threads list with 'truncated' get parameter.
128 129 """
129 130
130 131 post = get_object_or_404(Post, id=post_id)
131 132 truncated = PARAMETER_TRUNCATED in request.GET
132 133
133 134 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
134 135
135 136
136 137 def api_get_threads(request, count):
137 138 """
138 139 Gets the JSON thread opening posts list.
139 140 Parameters that can be used for filtering:
140 141 tag, offset (from which thread to get results)
141 142 """
142 143
143 144 if PARAMETER_TAG in request.GET:
144 145 tag_name = request.GET[PARAMETER_TAG]
145 146 if tag_name is not None:
146 147 tag = get_object_or_404(Tag, name=tag_name)
147 148 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
148 149 else:
149 150 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
150 151
151 152 if PARAMETER_OFFSET in request.GET:
152 153 offset = request.GET[PARAMETER_OFFSET]
153 154 offset = int(offset) if offset is not None else 0
154 155 else:
155 156 offset = 0
156 157
157 158 threads = threads.order_by('-bump_time')
158 159 threads = threads[offset:offset + int(count)]
159 160
160 161 opening_posts = []
161 162 for thread in threads:
162 163 opening_post = thread.get_opening_post()
163 164
164 165 # TODO Add tags, replies and images count
165 166 post_data = opening_post.get_post_data(include_last_update=True)
166 167 post_data['status'] = thread.get_status()
167 168
168 169 opening_posts.append(post_data)
169 170
170 171 return HttpResponse(content=json.dumps(opening_posts))
171 172
172 173
173 174 # TODO Test this
174 175 def api_get_tags(request):
175 176 """
176 177 Gets all tags or user tags.
177 178 """
178 179
179 180 # TODO Get favorite tags for the given user ID
180 181
181 182 tags = TagAlias.objects.all()
182 183
183 184 term = request.GET.get('term')
184 185 if term is not None:
185 186 tags = tags.filter(name__contains=term)
186 187
187 188 tag_names = [tag.name for tag in tags]
188 189
189 190 return HttpResponse(content=json.dumps(tag_names))
190 191
191 192
192 193 def api_get_stickers(request):
193 194 term = request.GET.get('term')
194 195 if not term:
195 196 return HttpResponseBadRequest()
196 197
197 global_stickers = AttachmentSticker.objects.filter(name__contains=term)
198 global_stickers = AttachmentSticker.objects.filter(Q(name__icontains=term) | Q(stickerpack__name__icontains=term))
198 199 local_stickers = [sticker for sticker in get_settings_manager(request).get_stickers() if term in sticker.name]
199 200 stickers = list(global_stickers) + local_stickers
200 201
201 202 image_dict = [{'thumb': sticker.attachment.get_thumb_url(),
202 'alias': sticker.name}
203 'alias': str(sticker)}
203 204 for sticker in stickers]
204 205
205 206 return HttpResponse(content=json.dumps(image_dict))
206 207
207 208
208 209 # TODO The result can be cached by the thread last update time
209 210 # TODO Test this
210 211 def api_get_thread_posts(request, opening_post_id):
211 212 """
212 213 Gets the JSON array of thread posts
213 214 """
214 215
215 216 opening_post = get_object_or_404(Post, id=opening_post_id)
216 217 thread = opening_post.get_thread()
217 218 posts = thread.get_replies()
218 219
219 220 json_data = {
220 221 'posts': [],
221 222 'last_update': None,
222 223 }
223 224 json_post_list = []
224 225
225 226 for post in posts:
226 227 json_post_list.append(post.get_post_data())
227 228 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
228 229 json_data['posts'] = json_post_list
229 230
230 231 return HttpResponse(content=json.dumps(json_data))
231 232
232 233
233 234 def api_get_notifications(request, username):
234 235 last_notification_id_str = request.GET.get('last', None)
235 236 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
236 237
237 238 posts = Notification.objects.get_notification_posts(usernames=[username],
238 239 last=last_id)
239 240
240 241 json_post_list = []
241 242 for post in posts:
242 243 json_post_list.append(post.get_post_data())
243 244 return HttpResponse(content=json.dumps(json_post_list))
244 245
245 246
246 247 def api_get_post(request, post_id):
247 248 """
248 249 Gets the JSON of a post. This can be
249 250 used as and API for external clients.
250 251 """
251 252
252 253 post = get_object_or_404(Post, id=post_id)
253 254
254 255 json = serializers.serialize("json", [post], fields=(
255 256 "pub_time", "_text_rendered", "title", "text", "image",
256 257 "image_width", "image_height", "replies", "tags"
257 258 ))
258 259
259 260 return HttpResponse(content=json)
260 261
261 262
262 263 def api_get_preview(request):
263 264 raw_text = request.POST['raw_text']
264 265
265 266 parser = Parser()
266 267 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
267 268
268 269
269 270 def api_get_new_posts(request):
270 271 """
271 272 Gets favorite threads and unread posts count.
272 273 """
273 274 posts = list()
274 275
275 276 include_posts = 'include_posts' in request.GET
276 277
277 278 settings_manager = get_settings_manager(request)
278 279 fav_threads = settings_manager.get_fav_threads()
279 280 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
280 281 .order_by('-pub_time').prefetch_related('thread')
281 282
282 283 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
283 284 if include_posts:
284 285 new_post_threads = Thread.objects.get_new_posts(ops)
285 286 if new_post_threads:
286 287 thread_ids = {thread.id: thread for thread in new_post_threads}
287 288 else:
288 289 thread_ids = dict()
289 290
290 291 for op in fav_thread_ops:
291 292 fav_thread_dict = dict()
292 293
293 294 op_thread = op.get_thread()
294 295 if op_thread.id in thread_ids:
295 296 thread = thread_ids[op_thread.id]
296 297 new_post_count = thread.new_post_count
297 298 fav_thread_dict['newest_post_link'] = thread.get_replies()\
298 299 .filter(id__gt=fav_threads[str(op.id)])\
299 300 .first().get_absolute_url(thread=thread)
300 301 else:
301 302 new_post_count = 0
302 303 fav_thread_dict['new_post_count'] = new_post_count
303 304
304 305 fav_thread_dict['id'] = op.id
305 306
306 307 fav_thread_dict['post_url'] = op.get_link_view()
307 308 fav_thread_dict['title'] = op.title
308 309
309 310 posts.append(fav_thread_dict)
310 311 else:
311 312 fav_thread_dict = dict()
312 313 fav_thread_dict['new_post_count'] = \
313 314 Thread.objects.get_new_post_count(ops)
314 315 posts.append(fav_thread_dict)
315 316
316 317 return HttpResponse(content=json.dumps(posts))
General Comments 0
You need to be logged in to leave comments. Login now