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