##// END OF EJS Templates
Merged with default branch
neko259 -
r1360:94773499 merge decentral
parent child Browse files
Show More
@@ -0,0 +1,20 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0025_auto_20150825_2049'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='post',
16 name='opening',
17 field=models.BooleanField(default=False),
18 preserve_default=False,
19 ),
20 ]
@@ -0,0 +1,22 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations
5
6
7 class Migration(migrations.Migration):
8
9 def build_opening_flag(apps, schema_editor):
10 Post = apps.get_model('boards', 'Post')
11 for post in Post.objects.all():
12 op = Post.objects.filter(threads__in=[post.thread]).order_by('pub_time').first()
13 post.opening = op.id == post.id
14 post.save(update_fields=['opening'])
15
16 dependencies = [
17 ('boards', '0026_post_opening'),
18 ]
19
20 operations = [
21 migrations.RunPython(build_opening_flag),
22 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0027_auto_20150912_1632'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='threads',
17 field=models.ManyToManyField(to='boards.Thread', related_name='multi_replies', db_index=True),
18 ),
19 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0028_auto_20150928_2211'),
11 ]
12
13 operations = [
14 migrations.AddField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(to='boards.Tag', null=True),
18 ),
19 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0029_tag_parent'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(related_name='children', null=True, to='boards.Tag'),
18 ),
19 ]
@@ -0,0 +1,15 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0030_auto_20150929_1816'),
11 ('boards', '0026_auto_20150830_2006'),
12 ]
13
14 operations = [
15 ]
@@ -0,0 +1,66 b''
1 import os
2 import re
3
4 from django.core.files.uploadedfile import SimpleUploadedFile
5 from pytube import YouTube
6 import requests
7
8 from boards.utils import validate_file_size
9
10 YOUTUBE_VIDEO_FORMAT = 'webm'
11
12 HTTP_RESULT_OK = 200
13
14 HEADER_CONTENT_LENGTH = 'content-length'
15 HEADER_CONTENT_TYPE = 'content-type'
16
17 FILE_DOWNLOAD_CHUNK_BYTES = 100000
18
19 YOUTUBE_URL = re.compile(r'https?://www\.youtube\.com/watch\?v=\w+')
20
21
22 class Downloader:
23 @staticmethod
24 def handles(url: str) -> bool:
25 return False
26
27 @staticmethod
28 def download(url: str):
29 # Verify content headers
30 response_head = requests.head(url, verify=False)
31 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
32 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
33 if length_header:
34 length = int(length_header)
35 validate_file_size(length)
36 # Get the actual content into memory
37 response = requests.get(url, verify=False, stream=True)
38
39 # Download file, stop if the size exceeds limit
40 size = 0
41 content = b''
42 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
43 size += len(chunk)
44 validate_file_size(size)
45 content += chunk
46
47 if response.status_code == HTTP_RESULT_OK and content:
48 # Set a dummy file name that will be replaced
49 # anyway, just keep the valid extension
50 filename = 'file.' + content_type.split('/')[1]
51 return SimpleUploadedFile(filename, content, content_type)
52
53
54 class YouTubeDownloader(Downloader):
55 @staticmethod
56 def download(url: str):
57 yt = YouTube()
58 yt.from_url(url)
59 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
60 if len(videos) > 0:
61 video = videos[0]
62 return Downloader.download(video.url)
63
64 @staticmethod
65 def handles(url: str) -> bool:
66 return YOUTUBE_URL.match(url)
1 NO CONTENT: new file 100644, binary diff hidden
1 NO CONTENT: new file 100644, binary diff hidden
1 NO CONTENT: new file 100644, binary diff hidden
@@ -1,36 +1,37 b''
1 1 bc8fce57a613175450b8b6d933cdd85f22c04658 1.1
2 2 784258eb652c563c288ca7652c33f52cd4733d83 1.1-stable
3 3 1b53a22467a8fccc798935d7a26efe78e4bc7b25 1.2-stable
4 4 1713fb7543386089e364c39703b79e57d3d851f0 1.3
5 5 80f183ebbe132ea8433eacae9431360f31fe7083 1.4
6 6 4330ff5a2bf6c543d8aaae8a43de1dc062f3bd13 1.4.1
7 7 8531d7b001392289a6b761f38c73a257606552ad 1.5
8 8 78e843c8b04b5a81cee5aa24601e305fae75da24 1.5.1
9 9 4f92838730ed9aa1d17651bbcdca19a097fd0c37 1.6
10 10 4bac2f37ea463337ddd27f98e7985407a74de504 1.7
11 11 1c4febea92c6503ae557fba73b2768659ae90d24 1.7.1
12 12 56a4a4578fc454ee455e33dd74a2cc82234bcb59 1.7.2
13 13 34d6f3d5deb22be56b6c1512ec654bd7f6e03bcc 1.7.3
14 14 f5cca33d29c673b67d43f310bebc4e3a21c6d04c 1.7.4
15 15 7f7c33ba6e3f3797ca866c5ed5d358a2393f1371 1.8
16 16 a6b9dd9547bdc17b681502efcceb17aa5c09adf4 1.8.1
17 17 8318fa1615d1946e4519f5735ae880909521990d 2.0
18 18 e23590ee7e2067a3f0fc3cbcfd66404b47127feb 2.1
19 19 4d998aba79e4abf0a2e78e93baaa2c2800b1c49c 2.2
20 20 07fdef4ac33a859250d03f17c594089792bca615 2.2.1
21 21 bcc74d45f060ecd3ff06ff448165aea0d026cb3e 2.2.2
22 22 b0e629ff24eb47a449ecfb455dc6cc600d18c9e2 2.2.3
23 23 1b52ba60f17fd7c90912c14d9d17e880b7952d01 2.2.4
24 24 957e2fec91468f739b0fc2b9936d564505048c68 2.3.0
25 25 bb91141c6ea5c822ccbe2d46c3c48bdab683b77d 2.4.0
26 26 97eb184637e5691b288eaf6b03e8971f3364c239 2.5.0
27 27 119fafc5381b933bf30d97be0b278349f6135075 2.5.1
28 28 d528d76d3242cced614fa11bb63f3d342e4e1d09 2.5.2
29 29 1b631781ced34fbdeec032e7674bc4e131724699 2.6.0
30 30 0f2ef17dc0de678ada279bf7eedf6c5585f1fd7a 2.6.1
31 31 d53fc814a424d7fd90f23025c87b87baa164450e 2.7.0
32 32 836d8bb9fcd930b952b9a02029442c71c2441983 2.8.0
33 33 dfb6c481b1a2c33705de9a9b5304bc924c46b202 2.8.1
34 34 4a5bec08ccfb47a27f9e98698f12dd5b7246623b 2.8.2
35 35 604935b98f5b5e4a5e903594f048046e1fbb3519 2.8.3
36 36 c48ffdc671566069ed0f33644da1229277f3cd18 2.9.0
37 d66dc192d4e089ba85325afeef5229b73cb0fde4 2.10.0
@@ -1,148 +1,172 b''
1 1 from boards.models import Tag
2 from boards.models.thread import FAV_THREAD_NO_UPDATES
2 3
3 4 MAX_TRIPCODE_COLLISIONS = 50
4 5
5 6 __author__ = 'neko259'
6 7
7 8 SESSION_SETTING = 'setting'
8 9
9 10 # Remove this, it is not used any more cause there is a user's permission
10 11 PERMISSION_MODERATE = 'moderator'
11 12
12 13 SETTING_THEME = 'theme'
13 14 SETTING_FAVORITE_TAGS = 'favorite_tags'
15 SETTING_FAVORITE_THREADS = 'favorite_threads'
14 16 SETTING_HIDDEN_TAGS = 'hidden_tags'
15 17 SETTING_PERMISSIONS = 'permissions'
16 18 SETTING_USERNAME = 'username'
17 19 SETTING_LAST_NOTIFICATION_ID = 'last_notification'
18 20 SETTING_IMAGE_VIEWER = 'image_viewer'
19 21 SETTING_TRIPCODE = 'tripcode'
20 22
21 23 DEFAULT_THEME = 'md'
22 24
23 25
24 26 class SettingsManager:
25 27 """
26 28 Base settings manager class. get_setting and set_setting methods should
27 29 be overriden.
28 30 """
29 31 def __init__(self):
30 32 pass
31 33
32 34 def get_theme(self) -> str:
33 35 theme = self.get_setting(SETTING_THEME)
34 36 if not theme:
35 37 theme = DEFAULT_THEME
36 38 self.set_setting(SETTING_THEME, theme)
37 39
38 40 return theme
39 41
40 42 def set_theme(self, theme):
41 43 self.set_setting(SETTING_THEME, theme)
42 44
43 45 def has_permission(self, permission):
44 46 permissions = self.get_setting(SETTING_PERMISSIONS)
45 47 if permissions:
46 48 return permission in permissions
47 49 else:
48 50 return False
49 51
50 52 def get_setting(self, setting, default=None):
51 53 pass
52 54
53 55 def set_setting(self, setting, value):
54 56 pass
55 57
56 58 def add_permission(self, permission):
57 59 permissions = self.get_setting(SETTING_PERMISSIONS)
58 60 if not permissions:
59 61 permissions = [permission]
60 62 else:
61 63 permissions.append(permission)
62 64 self.set_setting(SETTING_PERMISSIONS, permissions)
63 65
64 66 def del_permission(self, permission):
65 67 permissions = self.get_setting(SETTING_PERMISSIONS)
66 68 if not permissions:
67 69 permissions = []
68 70 else:
69 71 permissions.remove(permission)
70 72 self.set_setting(SETTING_PERMISSIONS, permissions)
71 73
72 74 def get_fav_tags(self) -> list:
73 75 tag_names = self.get_setting(SETTING_FAVORITE_TAGS)
74 76 tags = []
75 77 if tag_names:
76 78 tags = list(Tag.objects.filter(name__in=tag_names))
77 79 return tags
78 80
79 81 def add_fav_tag(self, tag):
80 82 tags = self.get_setting(SETTING_FAVORITE_TAGS)
81 83 if not tags:
82 84 tags = [tag.name]
83 85 else:
84 86 if not tag.name in tags:
85 87 tags.append(tag.name)
86 88
87 89 tags.sort()
88 90 self.set_setting(SETTING_FAVORITE_TAGS, tags)
89 91
90 92 def del_fav_tag(self, tag):
91 93 tags = self.get_setting(SETTING_FAVORITE_TAGS)
92 94 if tag.name in tags:
93 95 tags.remove(tag.name)
94 96 self.set_setting(SETTING_FAVORITE_TAGS, tags)
95 97
96 98 def get_hidden_tags(self) -> list:
97 99 tag_names = self.get_setting(SETTING_HIDDEN_TAGS)
98 100 tags = []
99 101 if tag_names:
100 102 tags = list(Tag.objects.filter(name__in=tag_names))
101 103
102 104 return tags
103 105
104 106 def add_hidden_tag(self, tag):
105 107 tags = self.get_setting(SETTING_HIDDEN_TAGS)
106 108 if not tags:
107 109 tags = [tag.name]
108 110 else:
109 111 if not tag.name in tags:
110 112 tags.append(tag.name)
111 113
112 114 tags.sort()
113 115 self.set_setting(SETTING_HIDDEN_TAGS, tags)
114 116
115 117 def del_hidden_tag(self, tag):
116 118 tags = self.get_setting(SETTING_HIDDEN_TAGS)
117 119 if tag.name in tags:
118 120 tags.remove(tag.name)
119 121 self.set_setting(SETTING_HIDDEN_TAGS, tags)
120 122
123 def get_fav_threads(self) -> dict:
124 return self.get_setting(SETTING_FAVORITE_THREADS, default=dict())
125
126 def add_or_read_fav_thread(self, opening_post):
127 threads = self.get_fav_threads()
128 thread = opening_post.get_thread()
129 # Don't check for new posts if the thread is archived already
130 if thread.is_archived():
131 last_id = FAV_THREAD_NO_UPDATES
132 else:
133 last_id = thread.get_replies().last().id
134 threads[str(opening_post.id)] = last_id
135 self.set_setting(SETTING_FAVORITE_THREADS, threads)
136
137 def del_fav_thread(self, opening_post):
138 threads = self.get_fav_threads()
139 if self.thread_is_fav(opening_post):
140 del threads[str(opening_post.id)]
141 self.set_setting(SETTING_FAVORITE_THREADS, threads)
142
143 def thread_is_fav(self, opening_post):
144 return str(opening_post.id) in self.get_fav_threads()
121 145
122 146 class SessionSettingsManager(SettingsManager):
123 147 """
124 148 Session-based settings manager. All settings are saved to the user's
125 149 session.
126 150 """
127 151 def __init__(self, session):
128 152 SettingsManager.__init__(self)
129 153 self.session = session
130 154
131 155 def get_setting(self, setting, default=None):
132 156 if setting in self.session:
133 157 return self.session[setting]
134 158 else:
135 159 self.set_setting(setting, default)
136 160 return default
137 161
138 162 def set_setting(self, setting, value):
139 163 self.session[setting] = value
140 164
141 165
142 166 def get_settings_manager(request) -> SettingsManager:
143 167 """
144 168 Get settings manager based on the request object. Currently only
145 169 session-based manager is supported. In the future, cookie-based or
146 170 database-based managers could be implemented.
147 171 """
148 172 return SessionSettingsManager(request.session)
@@ -1,71 +1,81 b''
1 1 from django.contrib import admin
2 2 from boards.models import Post, Tag, Ban, Thread, KeyPair, Banner
3 3 from django.utils.translation import ugettext_lazy as _
4 4
5 5
6 6 @admin.register(Post)
7 7 class PostAdmin(admin.ModelAdmin):
8 8
9 9 list_display = ('id', 'title', 'text', 'poster_ip')
10 10 list_filter = ('pub_time',)
11 11 search_fields = ('id', 'title', 'text', 'poster_ip')
12 12 exclude = ('referenced_posts', 'refmap')
13 13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images', 'uid')
14 14
15 15 def ban_poster(self, request, queryset):
16 16 bans = 0
17 17 for post in queryset:
18 18 poster_ip = post.poster_ip
19 19 ban, created = Ban.objects.get_or_create(ip=poster_ip)
20 20 if created:
21 21 bans += 1
22 22 self.message_user(request, _('{} posters were banned').format(bans))
23 23
24 24 actions = ['ban_poster']
25 25
26 26
27 27 @admin.register(Tag)
28 28 class TagAdmin(admin.ModelAdmin):
29 29
30 30 def thread_count(self, obj: Tag) -> int:
31 31 return obj.get_thread_count()
32 32
33 list_display = ('name', 'thread_count')
33 def display_children(self, obj: Tag):
34 return ', '.join([str(child) for child in obj.get_children().all()])
35
36 list_display = ('name', 'thread_count', 'display_children')
34 37 search_fields = ('name',)
35 38
36 39
37 40 @admin.register(Thread)
38 41 class ThreadAdmin(admin.ModelAdmin):
39 42
40 43 def title(self, obj: Thread) -> str:
41 44 return obj.get_opening_post().get_title()
42 45
43 46 def reply_count(self, obj: Thread) -> int:
44 47 return obj.get_reply_count()
45 48
46 49 def ip(self, obj: Thread):
47 50 return obj.get_opening_post().poster_ip
48 51
49 list_display = ('id', 'title', 'reply_count', 'archived', 'ip')
52 def display_tags(self, obj: Thread):
53 return ', '.join([str(tag) for tag in obj.get_tags().all()])
54
55 def op(self, obj: Thread):
56 return obj.get_opening_post_id()
57
58 list_display = ('id', 'op', 'title', 'reply_count', 'archived', 'ip',
59 'display_tags')
50 60 list_filter = ('bump_time', 'archived', 'bumpable')
51 61 search_fields = ('id', 'title')
52 62 filter_horizontal = ('tags',)
53 63
54 64
55 65 @admin.register(KeyPair)
56 66 class KeyPairAdmin(admin.ModelAdmin):
57 67 list_display = ('public_key', 'primary')
58 68 list_filter = ('primary',)
59 69 search_fields = ('public_key',)
60 70
61 71
62 72 @admin.register(Ban)
63 73 class BanAdmin(admin.ModelAdmin):
64 74 list_display = ('ip', 'can_read')
65 75 list_filter = ('can_read',)
66 76 search_fields = ('ip',)
67 77
68 78
69 79 @admin.register(Banner)
70 80 class BannerAdmin(admin.ModelAdmin):
71 81 list_display = ('title', 'text')
@@ -1,33 +1,33 b''
1 1 [Version]
2 Version = 2.9.0 Claire
2 Version = 2.10.0 BT
3 3 SiteName = Neboard DEV
4 4
5 5 [Cache]
6 6 # Timeout for caching, if cache is used
7 7 CacheTimeout = 600
8 8
9 9 [Forms]
10 10 # Max post length in characters
11 11 MaxTextLength = 30000
12 12 MaxFileSize = 8000000
13 13 LimitPostingSpeed = false
14 14
15 15 [Messages]
16 16 # Thread bumplimit
17 17 MaxPostsPerThread = 10
18 18 # Old posts will be archived or deleted if this value is reached
19 19 MaxThreadCount = 5
20 20
21 21 [View]
22 22 DefaultTheme = md
23 23 DefaultImageViewer = simple
24 24 LastRepliesCount = 3
25 25 ThreadsPerPage = 3
26 26
27 27 [Storage]
28 28 # Enable archiving threads instead of deletion when the thread limit is reached
29 29 ArchiveThreads = true
30 30
31 31 [External]
32 32 # Thread update
33 33 WebsocketsEnabled = false
@@ -1,63 +1,68 b''
1 1 from boards.abstracts.settingsmanager import get_settings_manager, \
2 2 SETTING_USERNAME, SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER
3 3 from boards.models.user import Notification
4 4
5 5 __author__ = 'neko259'
6 6
7 7 from boards import settings, utils
8 8 from boards.models import Post, Tag
9 9
10 10 CONTEXT_SITE_NAME = 'site_name'
11 11 CONTEXT_VERSION = 'version'
12 12 CONTEXT_MODERATOR = 'moderator'
13 13 CONTEXT_THEME_CSS = 'theme_css'
14 14 CONTEXT_THEME = 'theme'
15 15 CONTEXT_PPD = 'posts_per_day'
16 16 CONTEXT_TAGS = 'tags'
17 17 CONTEXT_USER = 'user'
18 18 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
19 19 CONTEXT_USERNAME = 'username'
20 20 CONTEXT_TAGS_STR = 'tags_str'
21 21 CONTEXT_IMAGE_VIEWER = 'image_viewer'
22 CONTEXT_HAS_FAV_THREADS = 'has_fav_threads'
22 23
23 24
24 25 def get_notifications(context, request):
25 26 settings_manager = get_settings_manager(request)
26 27 username = settings_manager.get_setting(SETTING_USERNAME)
27 28 new_notifications_count = 0
28 29 if username is not None and len(username) > 0:
29 30 last_notification_id = settings_manager.get_setting(
30 31 SETTING_LAST_NOTIFICATION_ID)
31 32
32 33 new_notifications_count = Notification.objects.get_notification_posts(
33 34 username=username, last=last_notification_id).count()
34 35 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
35 36 context[CONTEXT_USERNAME] = username
36 37
37 38
38 39 def user_and_ui_processor(request):
39 40 context = dict()
40 41
41 42 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
42 43
43 44 settings_manager = get_settings_manager(request)
44 45 fav_tags = settings_manager.get_fav_tags()
45 46 context[CONTEXT_TAGS] = fav_tags
47
46 48 context[CONTEXT_TAGS_STR] = Tag.objects.get_tag_url_list(fav_tags)
47 49 theme = settings_manager.get_theme()
48 50 context[CONTEXT_THEME] = theme
49 51 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
50 52
51 53 # This shows the moderator panel
52 54 context[CONTEXT_MODERATOR] = utils.is_moderator(request)
53 55
54 56 context[CONTEXT_VERSION] = settings.get('Version', 'Version')
55 57 context[CONTEXT_SITE_NAME] = settings.get('Version', 'SiteName')
56 58
57 59 context[CONTEXT_IMAGE_VIEWER] = settings_manager.get_setting(
58 60 SETTING_IMAGE_VIEWER,
59 61 default=settings.get('View', 'DefaultImageViewer'))
60 62
63 context[CONTEXT_HAS_FAV_THREADS] =\
64 len(settings_manager.get_fav_threads()) > 0
65
61 66 get_notifications(context, request)
62 67
63 68 return context
@@ -1,395 +1,372 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4
5 5 import pytz
6 6 from django import forms
7 7 from django.core.files.uploadedfile import SimpleUploadedFile
8 8 from django.core.exceptions import ObjectDoesNotExist
9 9 from django.forms.util import ErrorList
10 from django.utils.translation import ugettext_lazy as _
11 import requests
10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
12 11
13 12 from boards.mdx_neboard import formatters
13 from boards.models.attachment.downloaders import Downloader
14 14 from boards.models.post import TITLE_MAX_LENGTH
15 15 from boards.models import Tag, Post
16 from boards.utils import validate_file_size
16 17 from neboard import settings
17 18 import boards.settings as board_settings
18 19 import neboard
19 20
20 HEADER_CONTENT_LENGTH = 'content-length'
21 HEADER_CONTENT_TYPE = 'content-type'
22
23 21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
24 22
25 23 VETERAN_POSTING_DELAY = 5
26 24
27 25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
28 26 ATTRIBUTE_ROWS = 'rows'
29 27
30 28 LAST_POST_TIME = 'last_post_time'
31 29 LAST_LOGIN_TIME = 'last_login_time'
32 30 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
33 31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
34 32
35 33 LABEL_TITLE = _('Title')
36 34 LABEL_TEXT = _('Text')
37 35 LABEL_TAG = _('Tag')
38 36 LABEL_SEARCH = _('Search')
39 37
40 ERROR_SPEED = _('Please wait %s seconds before sending message')
38 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
39 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
41 40
42 41 TAG_MAX_LENGTH = 20
43 42
44 FILE_DOWNLOAD_CHUNK_BYTES = 100000
45
46 HTTP_RESULT_OK = 200
47
48 43 TEXTAREA_ROWS = 4
49 44
50 45
51 46 def get_timezones():
52 47 timezones = []
53 48 for tz in pytz.common_timezones:
54 49 timezones.append((tz, tz),)
55 50 return timezones
56 51
57 52
58 53 class FormatPanel(forms.Textarea):
59 54 """
60 55 Panel for text formatting. Consists of buttons to add different tags to the
61 56 form text area.
62 57 """
63 58
64 59 def render(self, name, value, attrs=None):
65 60 output = '<div id="mark-panel">'
66 61 for formatter in formatters:
67 62 output += '<span class="mark_btn"' + \
68 63 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
69 64 '\', \'' + formatter.format_right + '\')">' + \
70 65 formatter.preview_left + formatter.name + \
71 66 formatter.preview_right + '</span>'
72 67
73 68 output += '</div>'
74 69 output += super(FormatPanel, self).render(name, value, attrs=None)
75 70
76 71 return output
77 72
78 73
79 74 class PlainErrorList(ErrorList):
80 75 def __unicode__(self):
81 76 return self.as_text()
82 77
83 78 def as_text(self):
84 79 return ''.join(['(!) %s ' % e for e in self])
85 80
86 81
87 82 class NeboardForm(forms.Form):
88 83 """
89 84 Form with neboard-specific formatting.
90 85 """
91 86
92 87 def as_div(self):
93 88 """
94 89 Returns this form rendered as HTML <as_div>s.
95 90 """
96 91
97 92 return self._html_output(
98 93 # TODO Do not show hidden rows in the list here
99 94 normal_row='<div class="form-row">'
100 95 '<div class="form-label">'
101 96 '%(label)s'
102 97 '</div>'
103 98 '<div class="form-input">'
104 99 '%(field)s'
105 100 '</div>'
106 101 '</div>'
107 102 '<div class="form-row">'
108 103 '%(help_text)s'
109 104 '</div>',
110 105 error_row='<div class="form-row">'
111 106 '<div class="form-label"></div>'
112 107 '<div class="form-errors">%s</div>'
113 108 '</div>',
114 109 row_ender='</div>',
115 110 help_text_html='%s',
116 111 errors_on_separate_row=True)
117 112
118 113 def as_json_errors(self):
119 114 errors = []
120 115
121 116 for name, field in list(self.fields.items()):
122 117 if self[name].errors:
123 118 errors.append({
124 119 'field': name,
125 120 'errors': self[name].errors.as_text(),
126 121 })
127 122
128 123 return errors
129 124
130 125
131 126 class PostForm(NeboardForm):
132 127
133 128 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
134 129 label=LABEL_TITLE,
135 130 widget=forms.TextInput(
136 131 attrs={ATTRIBUTE_PLACEHOLDER:
137 132 'test#tripcode'}))
138 133 text = forms.CharField(
139 134 widget=FormatPanel(attrs={
140 135 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
141 136 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
142 137 }),
143 138 required=False, label=LABEL_TEXT)
144 139 file = forms.FileField(required=False, label=_('File'),
145 140 widget=forms.ClearableFileInput(
146 141 attrs={'accept': 'file/*'}))
147 142 file_url = forms.CharField(required=False, label=_('File URL'),
148 143 widget=forms.TextInput(
149 144 attrs={ATTRIBUTE_PLACEHOLDER:
150 145 'http://example.com/image.png'}))
151 146
152 147 # This field is for spam prevention only
153 148 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
154 149 widget=forms.TextInput(attrs={
155 150 'class': 'form-email'}))
156 151 threads = forms.CharField(required=False, label=_('Additional threads'),
157 152 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
158 153 '123 456 789'}))
159 154
160 155 session = None
161 156 need_to_ban = False
162 157
163 158 def clean_title(self):
164 159 title = self.cleaned_data['title']
165 160 if title:
166 161 if len(title) > TITLE_MAX_LENGTH:
167 162 raise forms.ValidationError(_('Title must have less than %s '
168 163 'characters') %
169 164 str(TITLE_MAX_LENGTH))
170 165 return title
171 166
172 167 def clean_text(self):
173 168 text = self.cleaned_data['text'].strip()
174 169 if text:
175 170 max_length = board_settings.get_int('Forms', 'MaxTextLength')
176 171 if len(text) > max_length:
177 172 raise forms.ValidationError(_('Text must have less than %s '
178 173 'characters') % str(max_length))
179 174 return text
180 175
181 176 def clean_file(self):
182 177 file = self.cleaned_data['file']
183 178
184 179 if file:
185 self.validate_file_size(file.size)
180 validate_file_size(file.size)
186 181
187 182 return file
188 183
189 184 def clean_file_url(self):
190 185 url = self.cleaned_data['file_url']
191 186
192 187 file = None
193 188 if url:
194 189 file = self._get_file_from_url(url)
195 190
196 191 if not file:
197 192 raise forms.ValidationError(_('Invalid URL'))
198 193 else:
199 self.validate_file_size(file.size)
194 validate_file_size(file.size)
200 195
201 196 return file
202 197
203 198 def clean_threads(self):
204 199 threads_str = self.cleaned_data['threads']
205 200
206 201 if len(threads_str) > 0:
207 202 threads_id_list = threads_str.split(' ')
208 203
209 204 threads = list()
210 205
211 206 for thread_id in threads_id_list:
212 207 try:
213 208 thread = Post.objects.get(id=int(thread_id))
214 209 if not thread.is_opening() or thread.get_thread().archived:
215 210 raise ObjectDoesNotExist()
216 211 threads.append(thread)
217 212 except (ObjectDoesNotExist, ValueError):
218 213 raise forms.ValidationError(_('Invalid additional thread list'))
219 214
220 215 return threads
221 216
222 217 def clean(self):
223 218 cleaned_data = super(PostForm, self).clean()
224 219
225 220 if cleaned_data['email']:
226 221 self.need_to_ban = True
227 222 raise forms.ValidationError('A human cannot enter a hidden field')
228 223
229 224 if not self.errors:
230 225 self._clean_text_file()
231 226
232 227 if not self.errors and self.session:
233 228 self._validate_posting_speed()
234 229
235 230 return cleaned_data
236 231
237 232 def get_file(self):
238 233 """
239 234 Gets file from form or URL.
240 235 """
241 236
242 237 file = self.cleaned_data['file']
243 238 return file or self.cleaned_data['file_url']
244 239
245 240 def get_tripcode(self):
246 241 title = self.cleaned_data['title']
247 242 if title is not None and '#' in title:
248 243 code = title.split('#', maxsplit=1)[1] + neboard.settings.SECRET_KEY
249 244 return hashlib.md5(code.encode()).hexdigest()
250 245
251 246 def get_title(self):
252 247 title = self.cleaned_data['title']
253 248 if title is not None and '#' in title:
254 249 return title.split('#', maxsplit=1)[0]
255 250 else:
256 251 return title
257 252
258 253 def _clean_text_file(self):
259 254 text = self.cleaned_data.get('text')
260 255 file = self.get_file()
261 256
262 257 if (not text) and (not file):
263 258 error_message = _('Either text or file must be entered.')
264 259 self._errors['text'] = self.error_class([error_message])
265 260
266 261 def _validate_posting_speed(self):
267 262 can_post = True
268 263
269 264 posting_delay = settings.POSTING_DELAY
270 265
271 266 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
272 267 now = time.time()
273 268
274 269 current_delay = 0
275 need_delay = False
276 270
277 if not LAST_POST_TIME in self.session:
271 if LAST_POST_TIME not in self.session:
278 272 self.session[LAST_POST_TIME] = now
279 273
280 274 need_delay = True
281 275 else:
282 276 last_post_time = self.session.get(LAST_POST_TIME)
283 277 current_delay = int(now - last_post_time)
284 278
285 279 need_delay = current_delay < posting_delay
286 280
287 281 if need_delay:
288 error_message = ERROR_SPEED % str(posting_delay
289 - current_delay)
282 delay = posting_delay - current_delay
283 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
284 delay) % {'delay': delay}
290 285 self._errors['text'] = self.error_class([error_message])
291 286
292 287 can_post = False
293 288
294 289 if can_post:
295 290 self.session[LAST_POST_TIME] = now
296 291
297 def validate_file_size(self, size: int):
298 max_size = board_settings.get_int('Forms', 'MaxFileSize')
299 if size > max_size:
300 raise forms.ValidationError(
301 _('File must be less than %s bytes')
302 % str(max_size))
303
304 292 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
305 293 """
306 294 Gets an file file from URL.
307 295 """
308 296
309 297 img_temp = None
310 298
311 299 try:
312 # Verify content headers
313 response_head = requests.head(url, verify=False)
314 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
315 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
316 if length_header:
317 length = int(length_header)
318 self.validate_file_size(length)
319 # Get the actual content into memory
320 response = requests.get(url, verify=False, stream=True)
321
322 # Download file, stop if the size exceeds limit
323 size = 0
324 content = b''
325 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
326 size += len(chunk)
327 self.validate_file_size(size)
328 content += chunk
329
330 if response.status_code == HTTP_RESULT_OK and content:
331 # Set a dummy file name that will be replaced
332 # anyway, just keep the valid extension
333 filename = 'file.' + content_type.split('/')[1]
334 img_temp = SimpleUploadedFile(filename, content,
335 content_type)
300 for downloader in Downloader.__subclasses__():
301 if downloader.handles(url):
302 return downloader.download(url)
303 # If nobody of the specific downloaders handles this, use generic
304 # one
305 return Downloader.download(url)
306 except forms.ValidationError as e:
307 raise e
336 308 except Exception as e:
337 309 # Just return no file
338 310 pass
339 311
340 return img_temp
341
342 312
343 313 class ThreadForm(PostForm):
344 314
345 315 tags = forms.CharField(
346 316 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
347 317 max_length=100, label=_('Tags'), required=True)
348 318
349 319 def clean_tags(self):
350 320 tags = self.cleaned_data['tags'].strip()
351 321
352 322 if not tags or not REGEX_TAGS.match(tags):
353 323 raise forms.ValidationError(
354 324 _('Inappropriate characters in tags.'))
355 325
356 326 required_tag_exists = False
357 for tag in tags.split():
358 try:
359 Tag.objects.get(name=tag.strip().lower(), required=True)
327 tag_set = set()
328 for tag_string in tags.split():
329 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
330 tag_set.add(tag)
331
332 # If this is a new tag, don't check for its parents because nobody
333 # added them yet
334 if not created:
335 tag_set |= tag.get_all_parents()
336
337 for tag in tag_set:
338 if tag.required:
360 339 required_tag_exists = True
361 340 break
362 except ObjectDoesNotExist:
363 pass
364 341
365 342 if not required_tag_exists:
366 343 all_tags = Tag.objects.filter(required=True)
367 344 raise forms.ValidationError(
368 345 _('Need at least one section.'))
369 346
370 return tags
347 return tag_set
371 348
372 349 def clean(self):
373 350 cleaned_data = super(ThreadForm, self).clean()
374 351
375 352 return cleaned_data
376 353
377 354
378 355 class SettingsForm(NeboardForm):
379 356
380 357 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
381 358 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
382 359 username = forms.CharField(label=_('User name'), required=False)
383 360 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
384 361
385 362 def clean_username(self):
386 363 username = self.cleaned_data['username']
387 364
388 365 if username and not REGEX_TAGS.match(username):
389 366 raise forms.ValidationError(_('Inappropriate characters.'))
390 367
391 368 return username
392 369
393 370
394 371 class SearchForm(NeboardForm):
395 372 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
1 NO CONTENT: modified file, binary diff hidden
@@ -1,462 +1,492 b''
1 1 # SOME DESCRIPTIVE TITLE.
2 2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 3 # This file is distributed under the same license as the PACKAGE package.
4 4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 5 #
6 6 msgid ""
7 7 msgstr ""
8 8 "Project-Id-Version: PACKAGE VERSION\n"
9 9 "Report-Msgid-Bugs-To: \n"
10 "POT-Creation-Date: 2015-08-22 15:07+0300\n"
10 "POT-Creation-Date: 2015-09-12 12:48+0300\n"
11 11 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12 12 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13 13 "Language-Team: LANGUAGE <LL@li.org>\n"
14 14 "Language: ru\n"
15 15 "MIME-Version: 1.0\n"
16 16 "Content-Type: text/plain; charset=UTF-8\n"
17 17 "Content-Transfer-Encoding: 8bit\n"
18 18 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
19 19 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
20 20
21 21 #: admin.py:22
22 22 msgid "{} posters were banned"
23 23 msgstr ""
24 24
25 25 #: authors.py:9
26 26 msgid "author"
27 27 msgstr "Π°Π²Ρ‚ΠΎΡ€"
28 28
29 29 #: authors.py:10
30 30 msgid "developer"
31 31 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ"
32 32
33 33 #: authors.py:11
34 34 msgid "javascript developer"
35 35 msgstr "Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ javascript"
36 36
37 37 #: authors.py:12
38 38 msgid "designer"
39 39 msgstr "Π΄ΠΈΠ·Π°ΠΉΠ½Π΅Ρ€"
40 40
41 #: forms.py:31
41 #: forms.py:30
42 42 msgid "Type message here. Use formatting panel for more advanced usage."
43 43 msgstr ""
44 44 "Π’Π²ΠΎΠ΄ΠΈΡ‚Π΅ сообщСниС сюда. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ панСль для Π±ΠΎΠ»Π΅Π΅ слоТного форматирования."
45 45
46 #: forms.py:32
46 #: forms.py:31
47 47 msgid "music images i_dont_like_tags"
48 48 msgstr "ΠΌΡƒΠ·Ρ‹ΠΊΠ° ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Ρ‚Π΅Π³ΠΈ_Π½Π΅_Π½ΡƒΠΆΠ½Ρ‹"
49 49
50 #: forms.py:34
50 #: forms.py:33
51 51 msgid "Title"
52 52 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ"
53 53
54 #: forms.py:35
54 #: forms.py:34
55 55 msgid "Text"
56 56 msgstr "ВСкст"
57 57
58 #: forms.py:36
58 #: forms.py:35
59 59 msgid "Tag"
60 60 msgstr "ΠœΠ΅Ρ‚ΠΊΠ°"
61 61
62 #: forms.py:37 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 63 msgid "Search"
64 64 msgstr "Поиск"
65 65
66 #: forms.py:39
67 #, python-format
68 msgid "Please wait %s seconds before sending message"
69 msgstr "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %s сСкунд ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
70
71 #: forms.py:140
66 #: forms.py:139
72 67 msgid "File"
73 68 msgstr "Π€Π°ΠΉΠ»"
74 69
75 #: forms.py:143
70 #: forms.py:142
76 71 msgid "File URL"
77 72 msgstr "URL Ρ„Π°ΠΉΠ»Π°"
78 73
79 #: forms.py:149
74 #: forms.py:148
80 75 msgid "e-mail"
81 76 msgstr ""
82 77
83 #: forms.py:152
78 #: forms.py:151
84 79 msgid "Additional threads"
85 80 msgstr "Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹"
86 81
87 #: forms.py:155
88 msgid "Tripcode"
89 msgstr "Π’Ρ€ΠΈΠΏΠΊΠΎΠ΄"
90
91 #: forms.py:164
82 #: forms.py:162
92 83 #, python-format
93 84 msgid "Title must have less than %s characters"
94 85 msgstr "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΠΌΠ΅Ρ‚ΡŒ мСньшС %s символов"
95 86
96 #: forms.py:174
87 #: forms.py:172
97 88 #, python-format
98 89 msgid "Text must have less than %s characters"
99 90 msgstr "ВСкст Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΊΠΎΡ€ΠΎΡ‡Π΅ %s символов"
100 91
101 #: forms.py:194
92 #: forms.py:192
102 93 msgid "Invalid URL"
103 94 msgstr "НСвСрный URL"
104 95
105 #: forms.py:215
96 #: forms.py:213
106 97 msgid "Invalid additional thread list"
107 98 msgstr "НСвСрный список Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… Ρ‚Π΅ΠΌ"
108 99
109 #: forms.py:251
100 #: forms.py:258
110 101 msgid "Either text or file must be entered."
111 102 msgstr "ВСкст ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Π²Π²Π΅Π΄Π΅Π½Ρ‹."
112 103
113 #: forms.py:289
114 #, python-format
115 msgid "File must be less than %s bytes"
116 msgstr "Π€Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΌΠ΅Π½Π΅Π΅ %s Π±Π°ΠΉΡ‚"
117
118 #: forms.py:335 templates/boards/all_threads.html:154
104 #: forms.py:317 templates/boards/all_threads.html:148
119 105 #: templates/boards/rss/post.html:10 templates/boards/tags.html:6
120 106 msgid "Tags"
121 107 msgstr "ΠœΠ΅Ρ‚ΠΊΠΈ"
122 108
123 #: forms.py:342
109 #: forms.py:324
124 110 msgid "Inappropriate characters in tags."
125 111 msgstr "НСдопустимыС символы Π² ΠΌΠ΅Ρ‚ΠΊΠ°Ρ…."
126 112
127 #: forms.py:356
113 #: forms.py:338
128 114 msgid "Need at least one section."
129 115 msgstr "НуТСн хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·Π΄Π΅Π»."
130 116
131 #: forms.py:368
117 #: forms.py:350
132 118 msgid "Theme"
133 119 msgstr "Π’Π΅ΠΌΠ°"
134 120
135 #: forms.py:369
136 #| msgid "Image view mode"
121 #: forms.py:351
137 122 msgid "Image view mode"
138 123 msgstr "Π Π΅ΠΆΠΈΠΌ просмотра ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
139 124
140 #: forms.py:370
125 #: forms.py:352
141 126 msgid "User name"
142 127 msgstr "Имя ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ"
143 128
144 #: forms.py:371
129 #: forms.py:353
145 130 msgid "Time zone"
146 131 msgstr "Часовой пояс"
147 132
148 #: forms.py:377
133 #: forms.py:359
149 134 msgid "Inappropriate characters."
150 135 msgstr "НСдопустимыС символы."
151 136
152 137 #: templates/boards/404.html:6
153 138 msgid "Not found"
154 139 msgstr "НС найдСно"
155 140
156 141 #: templates/boards/404.html:12
157 142 msgid "This page does not exist"
158 143 msgstr "Π­Ρ‚ΠΎΠΉ страницы Π½Π΅ сущСствуСт"
159 144
160 145 #: templates/boards/all_threads.html:35
161 146 msgid "Related message"
162 147 msgstr "БвязанноС сообщСниС"
163 148
164 #: templates/boards/all_threads.html:71
149 #: templates/boards/all_threads.html:69
165 150 msgid "Edit tag"
166 151 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΡƒ"
167 152
168 #: templates/boards/all_threads.html:79
153 #: templates/boards/all_threads.html:75
169 154 #, python-format
170 155 msgid ""
171 156 "This tag has %(thread_count)s threads (%(active_thread_count)s active) and "
172 157 "%(post_count)s posts."
173 158 msgstr ""
174 159 "Π‘ этой ΠΌΠ΅Ρ‚ΠΊΠΎΠΉ Π΅ΡΡ‚ΡŒ %(thread_count)s Ρ‚Π΅ΠΌ (%(active_thread_count)s Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ…) ΠΈ "
175 160 "%(post_count)s сообщСний."
176 161
177 #: templates/boards/all_threads.html:81
162 #: templates/boards/all_threads.html:77
178 163 msgid "Related tags:"
179 164 msgstr "ΠŸΠΎΡ…ΠΎΠΆΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
180 165
181 #: templates/boards/all_threads.html:96 templates/boards/feed.html:30
166 #: templates/boards/all_threads.html:90 templates/boards/feed.html:30
182 167 #: templates/boards/notifications.html:17 templates/search/search.html:26
183 168 msgid "Previous page"
184 169 msgstr "ΠŸΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π°Ρ страница"
185 170
186 #: templates/boards/all_threads.html:110
171 #: templates/boards/all_threads.html:104
187 172 #, python-format
188 msgid "Skipped %(count)s replies. Open thread to see all replies."
189 msgstr "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ². ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
173 #| msgid "Skipped %(count)s replies. Open thread to see all replies."
174 msgid "Skipped %(count)s reply. Open thread to see all replies."
175 msgid_plural "Skipped %(count)s replies. Open thread to see all replies."
176 msgstr[0] ""
177 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ %(count)s ΠΎΡ‚Π²Π΅Ρ‚. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
178 msgstr[1] ""
179 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚Π°. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
180 msgstr[2] ""
181 "ΠŸΡ€ΠΎΠΏΡƒΡ‰Π΅Π½ΠΎ %(count)s ΠΎΡ‚Π²Π΅Ρ‚ΠΎΠ². ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ‚Ρ€Π΅Π΄, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ всС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹."
190 182
191 #: templates/boards/all_threads.html:128 templates/boards/feed.html:40
183 #: templates/boards/all_threads.html:122 templates/boards/feed.html:40
192 184 #: templates/boards/notifications.html:27 templates/search/search.html:37
193 185 msgid "Next page"
194 186 msgstr "Π‘Π»Π΅Π΄ΡƒΡŽΡ‰Π°Ρ страница"
195 187
196 #: templates/boards/all_threads.html:133
188 #: templates/boards/all_threads.html:127
197 189 msgid "No threads exist. Create the first one!"
198 190 msgstr "НСт Ρ‚Π΅ΠΌ. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΡƒΡŽ!"
199 191
200 #: templates/boards/all_threads.html:139
192 #: templates/boards/all_threads.html:133
201 193 msgid "Create new thread"
202 194 msgstr "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ"
203 195
204 #: templates/boards/all_threads.html:144 templates/boards/preview.html:16
205 #: templates/boards/thread_normal.html:38
196 #: templates/boards/all_threads.html:138 templates/boards/preview.html:16
197 #: templates/boards/thread_normal.html:51
206 198 msgid "Post"
207 199 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ"
208 200
209 #: templates/boards/all_threads.html:149
201 #: templates/boards/all_threads.html:139 templates/boards/preview.html:6
202 #: templates/boards/staticpages/help.html:21
203 #: templates/boards/thread_normal.html:52
204 msgid "Preview"
205 msgstr "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€"
206
207 #: templates/boards/all_threads.html:144
210 208 msgid "Tags must be delimited by spaces. Text or image is required."
211 209 msgstr ""
212 210 "ΠœΠ΅Ρ‚ΠΊΠΈ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ Ρ€Π°Π·Π΄Π΅Π»Π΅Π½Ρ‹ ΠΏΡ€ΠΎΠ±Π΅Π»Π°ΠΌΠΈ. ВСкст ΠΈΠ»ΠΈ ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹."
213 211
214 #: templates/boards/all_threads.html:151 templates/boards/preview.html:6
215 #: templates/boards/staticpages/help.html:21
216 #: templates/boards/thread_normal.html:42
217 msgid "Preview"
218 msgstr "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€"
219
220 #: templates/boards/all_threads.html:153 templates/boards/thread_normal.html:45
212 #: templates/boards/all_threads.html:147 templates/boards/thread_normal.html:58
221 213 msgid "Text syntax"
222 214 msgstr "Бинтаксис тСкста"
223 215
224 #: templates/boards/all_threads.html:167 templates/boards/feed.html:53
216 #: templates/boards/all_threads.html:161 templates/boards/feed.html:53
225 217 msgid "Pages:"
226 218 msgstr "Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Ρ‹: "
227 219
228 220 #: templates/boards/authors.html:6 templates/boards/authors.html.py:12
229 221 msgid "Authors"
230 222 msgstr "Авторы"
231 223
232 224 #: templates/boards/authors.html:26
233 225 msgid "Distributed under the"
234 226 msgstr "РаспространяСтся ΠΏΠΎΠ΄"
235 227
236 228 #: templates/boards/authors.html:28
237 229 msgid "license"
238 230 msgstr "Π»ΠΈΡ†Π΅Π½Π·ΠΈΠ΅ΠΉ"
239 231
240 232 #: templates/boards/authors.html:30
241 233 msgid "Repository"
242 234 msgstr "Π Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ"
243 235
244 236 #: templates/boards/base.html:14 templates/boards/base.html.py:41
245 237 msgid "Feed"
246 238 msgstr "Π›Π΅Π½Ρ‚Π°"
247 239
248 240 #: templates/boards/base.html:31
249 241 msgid "All threads"
250 242 msgstr "ВсС Ρ‚Π΅ΠΌΡ‹"
251 243
252 244 #: templates/boards/base.html:37
253 245 msgid "Add tags"
254 246 msgstr "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚ΠΊΠΈ"
255 247
256 248 #: templates/boards/base.html:39
257 249 msgid "Tag management"
258 250 msgstr "Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠ°ΠΌΠΈ"
259 251
260 252 #: templates/boards/base.html:39
261 253 msgid "tags"
262 254 msgstr "ΠΌΠ΅Ρ‚ΠΊΠΈ"
263 255
264 256 #: templates/boards/base.html:40
265 257 msgid "search"
266 258 msgstr "поиск"
267 259
268 260 #: templates/boards/base.html:41 templates/boards/feed.html:11
269 261 msgid "feed"
270 262 msgstr "Π»Π΅Π½Ρ‚Π°"
271 263
272 264 #: templates/boards/base.html:42 templates/boards/random.html:6
273 265 msgid "Random images"
274 266 msgstr "Π‘Π»ΡƒΡ‡Π°ΠΉΠ½Ρ‹Π΅ изобраТСния"
275 267
276 268 #: templates/boards/base.html:42
277 269 msgid "random"
278 270 msgstr "случайныС"
279 271
280 272 #: templates/boards/base.html:45 templates/boards/base.html.py:46
281 273 #: templates/boards/notifications.html:8
282 274 msgid "Notifications"
283 275 msgstr "УвСдомлСния"
284 276
285 277 #: templates/boards/base.html:53 templates/boards/settings.html:8
286 278 msgid "Settings"
287 279 msgstr "Настройки"
288 280
289 #: templates/boards/base.html:66
281 #: templates/boards/base.html:79
290 282 msgid "Admin"
291 283 msgstr "АдминистрированиС"
292 284
293 #: templates/boards/base.html:68
285 #: templates/boards/base.html:81
294 286 #, python-format
295 287 msgid "Speed: %(ppd)s posts per day"
296 288 msgstr "Π‘ΠΊΠΎΡ€ΠΎΡΡ‚ΡŒ: %(ppd)s сообщСний Π² дСнь"
297 289
298 #: templates/boards/base.html:70
290 #: templates/boards/base.html:83
299 291 msgid "Up"
300 292 msgstr "Π’Π²Π΅Ρ€Ρ…"
301 293
302 294 #: templates/boards/feed.html:45
303 295 msgid "No posts exist. Create the first one!"
304 296 msgstr "НСт сообщСний. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ΅Ρ€Π²ΠΎΠ΅!"
305 297
306 #: templates/boards/post.html:30
298 #: templates/boards/post.html:32
307 299 msgid "Open"
308 300 msgstr "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ"
309 301
310 #: templates/boards/post.html:32 templates/boards/post.html.py:43
302 #: templates/boards/post.html:34 templates/boards/post.html.py:45
311 303 msgid "Reply"
312 304 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ"
313 305
314 #: templates/boards/post.html:38
306 #: templates/boards/post.html:40
315 307 msgid " in "
316 308 msgstr " Π² "
317 309
318 #: templates/boards/post.html:48
310 #: templates/boards/post.html:50
319 311 msgid "Edit"
320 312 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ"
321 313
322 #: templates/boards/post.html:50
314 #: templates/boards/post.html:52
323 315 msgid "Edit thread"
324 316 msgstr "Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ"
325 317
326 #: templates/boards/post.html:97
318 #: templates/boards/post.html:94
327 319 msgid "Replies"
328 320 msgstr "ΠžΡ‚Π²Π΅Ρ‚Ρ‹"
329 321
330 #: templates/boards/post.html:109 templates/boards/thread.html:34
331 msgid "messages"
332 msgstr "сообщСний"
322 #: templates/boards/post.html:105
323 #, python-format
324 msgid "%(count)s message"
325 msgid_plural "%(count)s messages"
326 msgstr[0] "%(count)s сообщСниС"
327 msgstr[1] "%(count)s сообщСния"
328 msgstr[2] "%(count)s сообщСний"
333 329
334 #: templates/boards/post.html:110 templates/boards/thread.html:35
335 msgid "images"
336 msgstr "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
330 #, python-format
331 msgid "Please wait %(delay)d second before sending message"
332 msgid_plural "Please wait %(delay)d seconds before sending message"
333 msgstr[0] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунду ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
334 msgstr[1] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунды ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
335 msgstr[2] "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π° ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ %(delay)d сСкунд ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ сообщСния"
336
337 #: templates/boards/post.html:106
338 #, python-format
339 msgid "%(count)s image"
340 msgid_plural "%(count)s images"
341 msgstr[0] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
342 msgstr[1] "%(count)s изобраТСния"
343 msgstr[2] "%(count)s ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
337 344
338 345 #: templates/boards/rss/post.html:5
339 346 msgid "Post image"
340 347 msgstr "Π˜Π·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ сообщСния"
341 348
342 349 #: templates/boards/settings.html:15
343 350 msgid "You are moderator."
344 351 msgstr "Π’Ρ‹ ΠΌΠΎΠ΄Π΅Ρ€Π°Ρ‚ΠΎΡ€."
345 352
346 353 #: templates/boards/settings.html:19
347 354 msgid "Hidden tags:"
348 355 msgstr "Π‘ΠΊΡ€Ρ‹Ρ‚Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
349 356
350 #: templates/boards/settings.html:27
357 #: templates/boards/settings.html:25
351 358 msgid "No hidden tags."
352 359 msgstr "НСт скрытых ΠΌΠ΅Ρ‚ΠΎΠΊ."
353 360
354 #: templates/boards/settings.html:29
355 msgid "Tripcode:"
356 msgstr "Π’Ρ€ΠΈΠΏΠΊΠΎΠ΄:"
357
358 #: templates/boards/settings.html:29
359 msgid "reset"
360 msgstr "ΡΠ±Ρ€ΠΎΡΠΈΡ‚ΡŒ"
361
362 #: templates/boards/settings.html:37
361 #: templates/boards/settings.html:34
363 362 msgid "Save"
364 363 msgstr "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ"
365 364
366 365 #: templates/boards/staticpages/banned.html:6
367 366 msgid "Banned"
368 367 msgstr "Π—Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½"
369 368
370 369 #: templates/boards/staticpages/banned.html:11
371 370 msgid "Your IP address has been banned. Contact the administrator"
372 371 msgstr "Π’Π°Ρˆ IP адрСс Π±Ρ‹Π» Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½. Π‘Π²ΡΠΆΠΈΡ‚Π΅ΡΡŒ с администратором"
373 372
374 373 #: templates/boards/staticpages/help.html:6
375 374 #: templates/boards/staticpages/help.html:10
376 375 msgid "Syntax"
377 376 msgstr "Бинтаксис"
378 377
379 378 #: templates/boards/staticpages/help.html:11
380 379 msgid "Italic text"
381 380 msgstr "ΠšΡƒΡ€ΡΠΈΠ²Π½Ρ‹ΠΉ тСкст"
382 381
383 382 #: templates/boards/staticpages/help.html:12
384 383 msgid "Bold text"
385 384 msgstr "ΠŸΠΎΠ»ΡƒΠΆΠΈΡ€Π½Ρ‹ΠΉ тСкст"
386 385
387 386 #: templates/boards/staticpages/help.html:13
388 387 msgid "Spoiler"
389 388 msgstr "Π‘ΠΏΠΎΠΉΠ»Π΅Ρ€"
390 389
391 390 #: templates/boards/staticpages/help.html:14
392 391 msgid "Link to a post"
393 392 msgstr "Бсылка Π½Π° сообщСниС"
394 393
395 394 #: templates/boards/staticpages/help.html:15
396 395 msgid "Strikethrough text"
397 396 msgstr "Π—Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚Ρ‹ΠΉ тСкст"
398 397
399 398 #: templates/boards/staticpages/help.html:16
400 399 msgid "Comment"
401 400 msgstr "ΠšΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ"
402 401
403 402 #: templates/boards/staticpages/help.html:17
404 403 #: templates/boards/staticpages/help.html:18
405 404 msgid "Quote"
406 405 msgstr "Π¦ΠΈΡ‚Π°Ρ‚Π°"
407 406
408 407 #: templates/boards/staticpages/help.html:21
409 408 msgid "You can try pasting the text and previewing the result here:"
410 409 msgstr "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΠΎΠΏΡ€ΠΎΠ±ΠΎΠ²Π°Ρ‚ΡŒ Π²ΡΡ‚Π°Π²ΠΈΡ‚ΡŒ тСкст ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ здСсь:"
411 410
412 411 #: templates/boards/tags.html:17
413 412 msgid "Sections:"
414 413 msgstr "Π Π°Π·Π΄Π΅Π»Ρ‹:"
415 414
416 415 #: templates/boards/tags.html:30
417 416 msgid "Other tags:"
418 417 msgstr "Π”Ρ€ΡƒΠ³ΠΈΠ΅ ΠΌΠ΅Ρ‚ΠΊΠΈ:"
419 418
420 419 #: templates/boards/tags.html:43
421 420 msgid "All tags..."
422 421 msgstr "ВсС ΠΌΠ΅Ρ‚ΠΊΠΈ..."
423 422
424 423 #: templates/boards/thread.html:15
425 424 msgid "Normal"
426 425 msgstr "ΠΠΎΡ€ΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ"
427 426
428 427 #: templates/boards/thread.html:16
429 428 msgid "Gallery"
430 429 msgstr "ГалСрСя"
431 430
432 431 #: templates/boards/thread.html:17
433 432 msgid "Tree"
434 433 msgstr "Π”Π΅Ρ€Π΅Π²ΠΎ"
435 434
436 435 #: templates/boards/thread.html:36
436 msgid "message"
437 msgid_plural "messages"
438 msgstr[0] "сообщСниС"
439 msgstr[1] "сообщСния"
440 msgstr[2] "сообщСний"
441
442 #: templates/boards/thread.html:39
443 msgid "image"
444 msgid_plural "images"
445 msgstr[0] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
446 msgstr[1] "изобраТСния"
447 msgstr[2] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
448
449 #: templates/boards/thread.html:41
437 450 msgid "Last update: "
438 451 msgstr "ПослСднСС обновлСниС: "
439 452
440 453 #: templates/boards/thread_gallery.html:36
441 454 msgid "No images."
442 455 msgstr "НСт ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ."
443 456
444 #: templates/boards/thread_normal.html:17
457 #: templates/boards/thread_normal.html:30
445 458 msgid "posts to bumplimit"
446 459 msgstr "сообщСний Π΄ΠΎ Π±Π°ΠΌΠΏΠ»ΠΈΠΌΠΈΡ‚Π°"
447 460
448 #: templates/boards/thread_normal.html:31
461 #: templates/boards/thread_normal.html:44
449 462 msgid "Reply to thread"
450 463 msgstr "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ Π² Ρ‚Π΅ΠΌΡƒ"
451 464
452 #: templates/boards/thread_normal.html:31
465 #: templates/boards/thread_normal.html:44
453 466 msgid "to message "
454 467 msgstr "Π½Π° сообщСниС"
455 468
456 #: templates/boards/thread_normal.html:46
469 #: templates/boards/thread_normal.html:59
457 470 msgid "Close form"
458 471 msgstr "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ Ρ„ΠΎΡ€ΠΌΡƒ"
459 472
460 473 #: templates/search/search.html:17
461 474 msgid "Ok"
462 475 msgstr "Ок"
476
477 #: utils.py:102
478 #, python-format
479 msgid "File must be less than %s bytes"
480 msgstr "Π€Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΠΌΠ΅Π½Π΅Π΅ %s Π±Π°ΠΉΡ‚"
481
482 msgid "favorites"
483 msgstr "ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅"
484
485 msgid "Loading..."
486 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
487
488 msgid "Category:"
489 msgstr "ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ:"
490
491 msgid "Subcategories:"
492 msgstr "ΠŸΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ:"
1 NO CONTENT: modified file, binary diff hidden
@@ -1,32 +1,55 b''
1 1 # SOME DESCRIPTIVE TITLE.
2 2 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 3 # This file is distributed under the same license as the PACKAGE package.
4 4 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
5 5 #
6 6 #, fuzzy
7 7 msgid ""
8 8 msgstr ""
9 9 "Project-Id-Version: PACKAGE VERSION\n"
10 10 "Report-Msgid-Bugs-To: \n"
11 "POT-Creation-Date: 2014-07-02 13:26+0300\n"
11 "POT-Creation-Date: 2015-09-04 18:47+0300\n"
12 12 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 13 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14 14 "Language-Team: LANGUAGE <LL@li.org>\n"
15 15 "Language: \n"
16 16 "MIME-Version: 1.0\n"
17 17 "Content-Type: text/plain; charset=UTF-8\n"
18 18 "Content-Transfer-Encoding: 8bit\n"
19 19 "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
20 20 "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
21 21
22 #: static/js/refpopup.js:58
22 #: static/js/3party/jquery-ui.min.js:8
23 msgid "'"
24 msgstr ""
25
26 #: static/js/refpopup.js:72
23 27 msgid "Loading..."
24 28 msgstr "Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ°..."
25 29
26 #: static/js/refpopup.js:77
30 #: static/js/refpopup.js:91
27 31 msgid "Post not found"
28 32 msgstr "Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ"
29 33
30 #: static/js/thread_update.js:279
34 #: static/js/thread_update.js:261
35 msgid "message"
36 msgid_plural "messages"
37 msgstr[0] "сообщСниС"
38 msgstr[1] "сообщСния"
39 msgstr[2] "сообщСний"
40
41 #: static/js/thread_update.js:262
42 msgid "image"
43 msgid_plural "images"
44 msgstr[0] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅"
45 msgstr[1] "изобраТСния"
46 msgstr[2] "ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ"
47
48 #: static/js/thread_update.js:445
31 49 msgid "Sending message..."
32 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΊΠ° сообщСния..." No newline at end of file
50 msgstr "ΠžΡ‚ΠΏΡ€Π°Π²ΠΊΠ° сообщСния..."
51
52 #: static/js/thread_update.js:449
53 msgid "Server error!"
54 msgstr "Ошибка сСрвСра!"
55
@@ -1,21 +1,28 b''
1 1 # -*- coding: utf-8 -*-
2 2 from __future__ import unicode_literals
3 3
4 4 from django.db import migrations
5 5
6 6
7 7 class Migration(migrations.Migration):
8 8
9 def refuild_refmap(apps, schema_editor):
9 def rebuild_refmap(apps, schema_editor):
10 10 Post = apps.get_model('boards', 'Post')
11 11 for post in Post.objects.all():
12 post.build_refmap()
12 refposts = list()
13 for refpost in post.referenced_posts.all():
14 result = '<a href="{}">&gt;&gt;{}</a>'.format(refpost.get_absolute_url(),
15 self.id)
16 if refpost.is_opening():
17 result = '<b>{}</b>'.format(result)
18 refposts += result
19 post.refmap = ', '.join(refposts)
13 20 post.save(update_fields=['refmap'])
14 21
15 22 dependencies = [
16 23 ('boards', '0024_post_tripcode'),
17 24 ]
18 25
19 26 operations = [
20 migrations.RunPython(refuild_refmap),
27 migrations.RunPython(rebuild_refmap),
21 28 ]
@@ -1,70 +1,84 b''
1 1 from django.template.defaultfilters import filesizeformat
2 2 from django.contrib.staticfiles.templatetags.staticfiles import static
3 3
4 4 FILE_STUB_IMAGE = 'images/file.png'
5 5
6 6 FILE_TYPES_VIDEO = (
7 7 'webm',
8 8 'mp4',
9 'mpeg',
9 10 )
10 11 FILE_TYPE_SVG = 'svg'
11 12 FILE_TYPES_AUDIO = (
12 13 'ogg',
13 14 'mp3',
15 'opus',
14 16 )
15 17
18 PLAIN_FILE_FORMATS = {
19 'pdf': 'pdf',
20 'djvu': 'djvu',
21 'txt': 'txt',
22 }
23
16 24
17 25 def get_viewers():
18 26 return AbstractViewer.__subclasses__()
19 27
20 28
21 29 class AbstractViewer:
22 30 def __init__(self, file, file_type):
23 31 self.file = file
24 32 self.file_type = file_type
25 33
26 34 @staticmethod
27 35 def supports(file_type):
28 36 return True
29 37
30 38 def get_view(self):
31 39 return '<div class="image">'\
32 40 '{}'\
33 41 '<div class="image-metadata"><a href="{}" download >{}, {}</a></div>'\
34 42 '</div>'.format(self.get_format_view(), self.file.url,
35 43 self.file_type, filesizeformat(self.file.size))
36 44
37 45 def get_format_view(self):
46 if self.file_type in PLAIN_FILE_FORMATS:
47 image = 'images/fileformats/{}.png'.format(
48 PLAIN_FILE_FORMATS[self.file_type])
49 else:
50 image = FILE_STUB_IMAGE
51
38 52 return '<a href="{}">'\
39 53 '<img src="{}" width="200" height="150"/>'\
40 '</a>'.format(self.file.url, static(FILE_STUB_IMAGE))
54 '</a>'.format(self.file.url, static(image))
41 55
42 56
43 57 class VideoViewer(AbstractViewer):
44 58 @staticmethod
45 59 def supports(file_type):
46 60 return file_type in FILE_TYPES_VIDEO
47 61
48 62 def get_format_view(self):
49 63 return '<video width="200" height="150" controls src="{}"></video>'\
50 64 .format(self.file.url)
51 65
52 66
53 67 class AudioViewer(AbstractViewer):
54 68 @staticmethod
55 69 def supports(file_type):
56 70 return file_type in FILE_TYPES_AUDIO
57 71
58 72 def get_format_view(self):
59 73 return '<audio controls src="{}"></audio>'.format(self.file.url)
60 74
61 75
62 76 class SvgViewer(AbstractViewer):
63 77 @staticmethod
64 78 def supports(file_type):
65 79 return file_type == FILE_TYPE_SVG
66 80
67 81 def get_format_view(self):
68 82 return '<a class="thumb" href="{}">'\
69 83 '<img class="post-image-preview" width="200" height="150" src="{}" />'\
70 84 '</a>'.format(self.file.url, self.file.url)
@@ -1,425 +1,411 b''
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
3 1 import logging
4 2 import re
5 3 import uuid
6 4
7 5 from django.core.exceptions import ObjectDoesNotExist
8 6 from django.core.urlresolvers import reverse
9 7 from django.db import models
10 8 from django.db.models import TextField, QuerySet
11 9 from django.template.loader import render_to_string
12 10 from django.utils import timezone
13 11
12 from boards import settings
14 13 from boards.abstracts.tripcode import Tripcode
15 14 from boards.mdx_neboard import Parser
16 from boards.models import KeyPair, GlobalId
17 from boards import settings
18 from boards.models import PostImage, Attachment
15 from boards.models import PostImage, Attachment, KeyPair, GlobalId
19 16 from boards.models.base import Viewable
20 17 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 18 from boards.models.post.manager import PostManager
22 19 from boards.models.user import Notification
23 20
24 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
25 WS_NOTIFICATION_TYPE = 'notification_type'
26
27 WS_CHANNEL_THREAD = "thread:"
28
29 21 APP_LABEL_BOARDS = 'boards'
30 22
31 23 BAN_REASON_AUTO = 'Auto'
32 24
33 25 IMAGE_THUMB_SIZE = (200, 150)
34 26
35 27 TITLE_MAX_LENGTH = 200
36 28
37 29 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 30 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
39 31 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
40 32 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
41 33
42 34 PARAMETER_TRUNCATED = 'truncated'
43 35 PARAMETER_TAG = 'tag'
44 36 PARAMETER_OFFSET = 'offset'
45 37 PARAMETER_DIFF_TYPE = 'type'
46 38 PARAMETER_CSS_CLASS = 'css_class'
47 39 PARAMETER_THREAD = 'thread'
48 40 PARAMETER_IS_OPENING = 'is_opening'
49 41 PARAMETER_MODERATOR = 'moderator'
50 42 PARAMETER_POST = 'post'
51 43 PARAMETER_OP_ID = 'opening_post_id'
52 44 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
53 45 PARAMETER_REPLY_LINK = 'reply_link'
54 46 PARAMETER_NEED_OP_DATA = 'need_op_data'
55 47
56 48 POST_VIEW_PARAMS = (
57 49 'need_op_data',
58 50 'reply_link',
59 51 'moderator',
60 52 'need_open_link',
61 53 'truncated',
62 54 'mode_tree',
63 55 )
64 56
65 57
66 58 class Post(models.Model, Viewable):
67 59 """A post is a message."""
68 60
69 61 objects = PostManager()
70 62
71 63 class Meta:
72 64 app_label = APP_LABEL_BOARDS
73 65 ordering = ('id',)
74 66
75 67 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
76 68 pub_time = models.DateTimeField()
77 69 text = TextField(blank=True, null=True)
78 70 _text_rendered = TextField(blank=True, null=True, editable=False)
79 71
80 72 images = models.ManyToManyField(PostImage, null=True, blank=True,
81 73 related_name='post_images', db_index=True)
82 74 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
83 related_name='attachment_posts')
75 related_name='attachment_posts')
84 76
85 77 poster_ip = models.GenericIPAddressField()
86 78
87 79 # TODO This field can be removed cause UID is used for update now
88 80 last_edit_time = models.DateTimeField()
89 81
90 82 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
91 83 null=True,
92 84 blank=True, related_name='refposts',
93 85 db_index=True)
94 86 refmap = models.TextField(null=True, blank=True)
95 threads = models.ManyToManyField('Thread', db_index=True)
87 threads = models.ManyToManyField('Thread', db_index=True,
88 related_name='multi_replies')
96 89 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
97 90
98 91 url = models.TextField()
99 92 uid = models.TextField(db_index=True)
100 93
101 94 # Global ID with author key. If the message was downloaded from another
102 95 # server, this indicates the server.
103 96 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
104 97
105 98 tripcode = models.CharField(max_length=50, null=True)
99 opening = models.BooleanField()
106 100
107 101 def __str__(self):
108 return 'P#{}/{}'.format(self.id, self.title)
102 return 'P#{}/{}'.format(self.id, self.get_title())
109 103
110 104 def get_referenced_posts(self):
111 105 threads = self.get_threads().all()
112 return self.referenced_posts.filter(threads__in=threads) \
106 return self.referenced_posts.filter(threads__in=threads)\
113 107 .order_by('pub_time').distinct().all()
114 108
115 109 def get_title(self) -> str:
116 """
117 Gets original post title or part of its text.
118 """
110 return self.title
119 111
120 title = self.title
112 def get_title_or_text(self):
113 title = self.get_title()
121 114 if not title:
122 title = self.get_text()
115 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
123 116
124 117 return title
125 118
126 119 def build_refmap(self) -> None:
127 120 """
128 121 Builds a replies map string from replies list. This is a cache to stop
129 122 the server from recalculating the map on every post show.
130 123 """
131 124
132 125 post_urls = [refpost.get_link_view()
133 126 for refpost in self.referenced_posts.all()]
134 127
135 128 self.refmap = ', '.join(post_urls)
136 129
137 130 def is_referenced(self) -> bool:
138 131 return self.refmap and len(self.refmap) > 0
139 132
140 133 def is_opening(self) -> bool:
141 134 """
142 135 Checks if this is an opening post or just a reply.
143 136 """
144 137
145 return self.get_thread().get_opening_post_id() == self.id
138 return self.opening
146 139
147 140 def get_absolute_url(self):
148 141 if self.url:
149 142 return self.url
150 143 else:
151 144 opening_id = self.get_thread().get_opening_post_id()
152 145 post_url = reverse('thread', kwargs={'post_id': opening_id})
153 146 if self.id != opening_id:
154 147 post_url += '#' + str(self.id)
155 148 return post_url
156 149
157 150 def get_thread(self):
158 151 return self.thread
159 152
160 153 def get_threads(self) -> QuerySet:
161 154 """
162 155 Gets post's thread.
163 156 """
164 157
165 158 return self.threads
166 159
167 160 def get_view(self, *args, **kwargs) -> str:
168 161 """
169 162 Renders post's HTML view. Some of the post params can be passed over
170 163 kwargs for the means of caching (if we view the thread, some params
171 164 are same for every post and don't need to be computed over and over.
172 165 """
173 166
174 167 thread = self.get_thread()
175 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
176
177 if is_opening:
178 opening_post_id = self.id
179 else:
180 opening_post_id = thread.get_opening_post_id()
181 168
182 169 css_class = 'post'
183 170 if thread.archived:
184 171 css_class += ' archive_post'
185 172 elif not thread.can_bump():
186 173 css_class += ' dead_post'
187 174
188 175 params = dict()
189 176 for param in POST_VIEW_PARAMS:
190 177 if param in kwargs:
191 178 params[param] = kwargs[param]
192 179
193 180 params.update({
194 181 PARAMETER_POST: self,
195 PARAMETER_IS_OPENING: is_opening,
182 PARAMETER_IS_OPENING: self.is_opening(),
196 183 PARAMETER_THREAD: thread,
197 184 PARAMETER_CSS_CLASS: css_class,
198 PARAMETER_OP_ID: opening_post_id,
199 185 })
200 186
201 187 return render_to_string('boards/post.html', params)
202 188
203 189 def get_search_view(self, *args, **kwargs):
204 190 return self.get_view(need_op_data=True, *args, **kwargs)
205 191
206 192 def get_first_image(self) -> PostImage:
207 193 return self.images.earliest('id')
208 194
209 195 def delete(self, using=None):
210 196 """
211 197 Deletes all post images and the post itself.
212 198 """
213 199
214 200 for image in self.images.all():
215 201 image_refs_count = image.post_images.count()
216 202 if image_refs_count == 1:
217 203 image.delete()
218 204
219 205 for attachment in self.attachments.all():
220 206 attachment_refs_count = attachment.attachment_posts.count()
221 207 if attachment_refs_count == 1:
222 208 attachment.delete()
223 209
224 210 if self.global_id:
225 211 self.global_id.delete()
226 212
227 213 thread = self.get_thread()
228 214 thread.last_edit_time = timezone.now()
229 215 thread.save()
230 216
231 217 super(Post, self).delete(using)
232 218
233 219 logging.getLogger('boards.post.delete').info(
234 220 'Deleted post {}'.format(self))
235 221
236 222 def set_global_id(self, key_pair=None):
237 223 """
238 224 Sets global id based on the given key pair. If no key pair is given,
239 225 default one is used.
240 226 """
241 227
242 228 if key_pair:
243 229 key = key_pair
244 230 else:
245 231 try:
246 232 key = KeyPair.objects.get(primary=True)
247 233 except KeyPair.DoesNotExist:
248 234 # Do not update the global id because there is no key defined
249 235 return
250 236 global_id = GlobalId(key_type=key.key_type,
251 237 key=key.public_key,
252 238 local_id=self.id)
253 239 global_id.save()
254 240
255 241 self.global_id = global_id
256 242
257 243 self.save(update_fields=['global_id'])
258 244
259 245 def get_pub_time_str(self):
260 246 return str(self.pub_time)
261 247
262 248 def get_replied_ids(self):
263 249 """
264 250 Gets ID list of the posts that this post replies.
265 251 """
266 252
267 253 raw_text = self.get_raw_text()
268 254
269 255 local_replied = REGEX_REPLY.findall(raw_text)
270 256 global_replied = []
271 257 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
272 258 key_type = match[0]
273 259 key = match[1]
274 260 local_id = match[2]
275 261
276 262 try:
277 263 global_id = GlobalId.objects.get(key_type=key_type,
278 264 key=key, local_id=local_id)
279 265 for post in Post.objects.filter(global_id=global_id).only('id'):
280 266 global_replied.append(post.id)
281 267 except GlobalId.DoesNotExist:
282 268 pass
283 269 return local_replied + global_replied
284 270
285 271 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
286 272 include_last_update=False) -> str:
287 273 """
288 274 Gets post HTML or JSON data that can be rendered on a page or used by
289 275 API.
290 276 """
291 277
292 278 return get_exporter(format_type).export(self, request,
293 279 include_last_update)
294 280
295 281 def notify_clients(self, recursive=True):
296 282 """
297 283 Sends post HTML data to the thread web socket.
298 284 """
299 285
300 286 if not settings.get_bool('External', 'WebsocketsEnabled'):
301 287 return
302 288
303 289 thread_ids = list()
304 290 for thread in self.get_threads().all():
305 291 thread_ids.append(thread.id)
306 292
307 293 thread.notify_clients()
308 294
309 295 if recursive:
310 296 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
311 297 post_id = reply_number.group(1)
312 298
313 299 try:
314 300 ref_post = Post.objects.get(id=post_id)
315 301
316 302 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
317 303 # If post is in this thread, its thread was already notified.
318 304 # Otherwise, notify its thread separately.
319 305 ref_post.notify_clients(recursive=False)
320 306 except ObjectDoesNotExist:
321 307 pass
322 308
323 309 def build_url(self):
324 310 self.url = self.get_absolute_url()
325 311 self.save(update_fields=['url'])
326 312
327 313 def save(self, force_insert=False, force_update=False, using=None,
328 314 update_fields=None):
329 315 self._text_rendered = Parser().parse(self.get_raw_text())
330 316
331 317 self.uid = str(uuid.uuid4())
332 318 if update_fields is not None and 'uid' not in update_fields:
333 319 update_fields += ['uid']
334 320
335 321 if self.id:
336 322 for thread in self.get_threads().all():
337 323 thread.last_edit_time = self.last_edit_time
338 324
339 thread.save(update_fields=['last_edit_time'])
325 thread.save(update_fields=['last_edit_time', 'bumpable'])
340 326
341 327 super().save(force_insert, force_update, using, update_fields)
342 328
343 329 def get_text(self) -> str:
344 330 return self._text_rendered
345 331
346 332 def get_raw_text(self) -> str:
347 333 return self.text
348 334
349 335 def get_sync_text(self) -> str:
350 336 """
351 337 Returns text applicable for sync. It has absolute post reflinks.
352 338 """
353 339
354 340 replacements = dict()
355 341 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
356 342 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
357 343 replacements[post_id] = absolute_post_id
358 344
359 345 text = self.get_raw_text()
360 346 for key in replacements:
361 347 text = text.replace('[post]{}[/post]'.format(key),
362 348 '[post]{}[/post]'.format(replacements[key]))
363 349
364 350 return text
365 351
366 352 def get_absolute_id(self) -> str:
367 353 """
368 354 If the post has many threads, shows its main thread OP id in the post
369 355 ID.
370 356 """
371 357
372 358 if self.get_threads().count() > 1:
373 359 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
374 360 else:
375 361 return str(self.id)
376 362
377 363 def connect_notifications(self):
378 364 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
379 365 user_name = reply_number.group(1).lower()
380 366 Notification.objects.get_or_create(name=user_name, post=self)
381 367
382 368 def connect_replies(self):
383 369 """
384 370 Connects replies to a post to show them as a reflink map
385 371 """
386 372
387 373 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
388 374 post_id = reply_number.group(1)
389 375
390 376 try:
391 377 referenced_post = Post.objects.get(id=post_id)
392 378
393 379 referenced_post.referenced_posts.add(self)
394 380 referenced_post.last_edit_time = self.pub_time
395 381 referenced_post.build_refmap()
396 382 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
397 383 except ObjectDoesNotExist:
398 384 pass
399 385
400 386 def connect_threads(self, opening_posts):
401 387 for opening_post in opening_posts:
402 388 threads = opening_post.get_threads().all()
403 389 for thread in threads:
404 390 if thread.can_bump():
405 391 thread.update_bump_status()
406 392
407 393 thread.last_edit_time = self.last_edit_time
408 394 thread.save(update_fields=['last_edit_time', 'bumpable'])
409 395 self.threads.add(opening_post.get_thread())
410 396
411 397 def get_tripcode(self):
412 398 if self.tripcode:
413 399 return Tripcode(self.tripcode)
414 400
415 401 def get_link_view(self):
416 402 """
417 403 Gets view of a reflink to the post.
418 404 """
419 405
420 406 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
421 self.id)
407 self.id)
422 408 if self.is_opening():
423 409 result = '<b>{}</b>'.format(result)
424 410
425 411 return result
@@ -1,140 +1,145 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 from django.db import models, transaction
5 5 from django.utils import timezone
6 6 from boards import utils
7 7 from boards.mdx_neboard import Parser
8 8 from boards.models import PostImage, Attachment
9 from boards.models.user import Ban
9 10 import boards.models
10 11
11 12 __author__ = 'vurdalak'
12 13
13 14
14 15 NO_IP = '0.0.0.0'
15 16 POSTS_PER_DAY_RANGE = 7
16 17
17 18 IMAGE_TYPES = (
18 19 'jpeg',
19 20 'jpg',
20 21 'png',
21 22 'bmp',
22 23 'gif',
23 24 )
24 25
25 26
26 27 class PostManager(models.Manager):
27 28 @transaction.atomic
28 29 def create_post(self, title: str, text: str, file=None, thread=None,
29 30 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
30 31 """
31 32 Creates new post
32 33 """
33 34
34 is_banned = boards.models.Ban.objects.filter(ip=ip).exists()
35 is_banned = Ban.objects.filter(ip=ip).exists()
35 36
36 37 # TODO Raise specific exception and catch it in the views
37 38 if is_banned:
38 39 raise Exception("This user is banned")
39 40
40 41 if not tags:
41 42 tags = []
42 43 if not opening_posts:
43 44 opening_posts = []
44 45
45 46 posting_time = timezone.now()
46 47 new_thread = False
47 48 if not thread:
48 49 thread = boards.models.thread.Thread.objects.create(
49 50 bump_time=posting_time, last_edit_time=posting_time)
50 51 list(map(thread.tags.add, tags))
51 52 boards.models.thread.Thread.objects.process_oldest_threads()
52 53 new_thread = True
53 54
54 55 pre_text = Parser().preparse(text)
55 56
56 57 post = self.create(title=title,
57 58 text=pre_text,
58 59 pub_time=posting_time,
59 60 poster_ip=ip,
60 61 thread=thread,
61 62 last_edit_time=posting_time,
62 tripcode=tripcode)
63 tripcode=tripcode,
64 opening=new_thread)
63 65 post.threads.add(thread)
64 66
65 67 logger = logging.getLogger('boards.post.create')
66 68
67 69 logger.info('Created post {} by {}'.format(post, post.poster_ip))
68 70
69 71 # TODO Move this to other place
70 72 if file:
71 73 file_type = file.name.split('.')[-1].lower()
72 74 if file_type in IMAGE_TYPES:
73 75 post.images.add(PostImage.objects.create_with_hash(file))
74 76 else:
75 77 post.attachments.add(Attachment.objects.create_with_hash(file))
76 78
77 79 post.build_url()
78 80 post.connect_replies()
79 81 post.connect_threads(opening_posts)
80 82 post.connect_notifications()
81 83 post.set_global_id()
82 84
83 85 # Thread needs to be bumped only when the post is already created
84 86 if not new_thread:
85 87 thread.last_edit_time = posting_time
86 88 thread.bump()
87 89 thread.save()
88 90
89 91 return post
90 92
91 93 def delete_posts_by_ip(self, ip):
92 94 """
93 95 Deletes all posts of the author with same IP
94 96 """
95 97
96 98 posts = self.filter(poster_ip=ip)
97 99 for post in posts:
98 100 post.delete()
99 101
100 102 @utils.cached_result()
101 103 def get_posts_per_day(self) -> float:
102 104 """
103 105 Gets average count of posts per day for the last 7 days
104 106 """
105 107
106 108 day_end = date.today()
107 109 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
108 110
109 111 day_time_start = timezone.make_aware(datetime.combine(
110 112 day_start, dtime()), timezone.get_current_timezone())
111 113 day_time_end = timezone.make_aware(datetime.combine(
112 114 day_end, dtime()), timezone.get_current_timezone())
113 115
114 116 posts_per_period = float(self.filter(
115 117 pub_time__lte=day_time_end,
116 118 pub_time__gte=day_time_start).count())
117 119
118 120 ppd = posts_per_period / POSTS_PER_DAY_RANGE
119 121
120 122 return ppd
121 123
122 124 @transaction.atomic
123 125 def import_post(self, title: str, text: str, pub_time: str, global_id,
124 126 opening_post=None, tags=list()):
125 if opening_post is None:
127 is_opening = opening_post is None
128 if is_opening:
126 129 thread = boards.models.thread.Thread.objects.create(
127 130 bump_time=pub_time, last_edit_time=pub_time)
128 131 list(map(thread.tags.add, tags))
129 132 else:
130 133 thread = opening_post.get_thread()
131 134
132 135 post = self.create(title=title, text=text,
133 136 pub_time=pub_time,
134 137 poster_ip=NO_IP,
135 138 last_edit_time=pub_time,
136 thread_id=thread.id, global_id=global_id)
139 thread_id=thread.id,
140 global_id=global_id,
141 opening=is_opening)
137 142
138 143 post.build_url()
139 144 post.connect_replies()
140 145 post.connect_notifications()
@@ -1,109 +1,134 b''
1 import hashlib
1 2 from django.template.loader import render_to_string
2 3 from django.db import models
3 4 from django.db.models import Count
4 5 from django.core.urlresolvers import reverse
5 6
6 7 from boards.models.base import Viewable
7 8 from boards.utils import cached_result
8 9 import boards
9 10
10 11 __author__ = 'neko259'
11 12
12 13
13 14 RELATED_TAGS_COUNT = 5
14 15
15 16
16 17 class TagManager(models.Manager):
17 18
18 19 def get_not_empty_tags(self):
19 20 """
20 21 Gets tags that have non-archived threads.
21 22 """
22 23
23 24 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
24 25 .order_by('-required', 'name')
25 26
26 27 def get_tag_url_list(self, tags: list) -> str:
27 28 """
28 29 Gets a comma-separated list of tag links.
29 30 """
30 31
31 32 return ', '.join([tag.get_view() for tag in tags])
32 33
33 34
34 35 class Tag(models.Model, Viewable):
35 36 """
36 37 A tag is a text node assigned to the thread. The tag serves as a board
37 38 section. There can be multiple tags for each thread
38 39 """
39 40
40 41 objects = TagManager()
41 42
42 43 class Meta:
43 44 app_label = 'boards'
44 45 ordering = ('name',)
45 46
46 47 name = models.CharField(max_length=100, db_index=True, unique=True)
47 48 required = models.BooleanField(default=False, db_index=True)
48 49 description = models.TextField(blank=True)
49 50
51 parent = models.ForeignKey('Tag', null=True, related_name='children')
52
50 53 def __str__(self):
51 54 return self.name
52 55
53 56 def is_empty(self) -> bool:
54 57 """
55 58 Checks if the tag has some threads.
56 59 """
57 60
58 61 return self.get_thread_count() == 0
59 62
60 63 def get_thread_count(self, archived=None) -> int:
61 64 threads = self.get_threads()
62 65 if archived is not None:
63 66 threads = threads.filter(archived=archived)
64 67 return threads.count()
65 68
66 69 def get_active_thread_count(self) -> int:
67 70 return self.get_thread_count(archived=False)
68 71
69 72 def get_absolute_url(self):
70 73 return reverse('tag', kwargs={'tag_name': self.name})
71 74
72 75 def get_threads(self):
73 76 return self.thread_tags.order_by('-bump_time')
74 77
75 78 def is_required(self):
76 79 return self.required
77 80
78 81 def get_view(self):
79 82 link = '<a class="tag" href="{}">{}</a>'.format(
80 83 self.get_absolute_url(), self.name)
81 84 if self.is_required():
82 85 link = '<b>{}</b>'.format(link)
83 86 return link
84 87
85 88 def get_search_view(self, *args, **kwargs):
86 89 return render_to_string('boards/tag.html', {
87 90 'tag': self,
88 91 })
89 92
90 93 @cached_result()
91 94 def get_post_count(self):
92 return self.get_threads().aggregate(num_posts=Count('post'))['num_posts']
95 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
93 96
94 97 def get_description(self):
95 98 return self.description
96 99
97 100 def get_random_image_post(self, archived=False):
98 101 posts = boards.models.Post.objects.annotate(images_count=Count(
99 102 'images')).filter(images_count__gt=0, threads__tags__in=[self])
100 103 if archived is not None:
101 104 posts = posts.filter(thread__archived=archived)
102 105 return posts.order_by('?').first()
103 106
104 107 def get_first_letter(self):
105 108 return self.name and self.name[0] or ''
106 109
107 110 def get_related_tags(self):
108 111 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
109 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
112 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
113
114 @cached_result()
115 def get_color(self):
116 """
117 Gets color hashed from the tag name.
118 """
119 return hashlib.md5(self.name.encode()).hexdigest()[:6]
120
121 def get_parent(self):
122 return self.parent
123
124 def get_all_parents(self):
125 parents = set()
126 parent = self.get_parent()
127 if parent and parent not in parents:
128 parents.add(parent)
129 parents |= parent.get_all_parents()
130
131 return parents
132
133 def get_children(self):
134 return self.children
@@ -1,230 +1,258 b''
1 1 import logging
2 2 from adjacent import Client
3 3
4 from django.db.models import Count, Sum, QuerySet
4 from django.db.models import Count, Sum, QuerySet, Q
5 5 from django.utils import timezone
6 6 from django.db import models
7 7
8 8 from boards import settings
9 9 import boards
10 10 from boards.utils import cached_result, datetime_to_epoch
11 11 from boards.models.post import Post
12 12 from boards.models.tag import Tag
13 13
14 FAV_THREAD_NO_UPDATES = -1
15
14 16
15 17 __author__ = 'neko259'
16 18
17 19
18 20 logger = logging.getLogger(__name__)
19 21
20 22
21 23 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 24 WS_NOTIFICATION_TYPE = 'notification_type'
23 25
24 26 WS_CHANNEL_THREAD = "thread:"
25 27
26 28
27 29 class ThreadManager(models.Manager):
28 30 def process_oldest_threads(self):
29 31 """
30 32 Preserves maximum thread count. If there are too many threads,
31 33 archive or delete the old ones.
32 34 """
33 35
34 36 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
35 37 thread_count = threads.count()
36 38
37 39 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
38 40 if thread_count > max_thread_count:
39 41 num_threads_to_delete = thread_count - max_thread_count
40 42 old_threads = threads[thread_count - num_threads_to_delete:]
41 43
42 44 for thread in old_threads:
43 45 if settings.get_bool('Storage', 'ArchiveThreads'):
44 46 self._archive_thread(thread)
45 47 else:
46 48 thread.delete()
47 49
48 50 logger.info('Processed %d old threads' % num_threads_to_delete)
49 51
50 52 def _archive_thread(self, thread):
51 53 thread.archived = True
52 54 thread.bumpable = False
53 55 thread.last_edit_time = timezone.now()
54 56 thread.update_posts_time()
55 57 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
56 58
59 def get_new_posts(self, datas):
60 query = None
61 # TODO Use classes instead of dicts
62 for data in datas:
63 if data['last_id'] != FAV_THREAD_NO_UPDATES:
64 q = (Q(id=data['op'].get_thread().id)
65 & Q(multi_replies__id__gt=data['last_id']))
66 if query is None:
67 query = q
68 else:
69 query = query | q
70 if query is not None:
71 return self.filter(query).annotate(
72 new_post_count=Count('multi_replies'))
73
74 def get_new_post_count(self, datas):
75 new_posts = self.get_new_posts(datas)
76 return new_posts.aggregate(total_count=Count('multi_replies'))\
77 ['total_count'] if new_posts else 0
78
57 79
58 80 def get_thread_max_posts():
59 81 return settings.get_int('Messages', 'MaxPostsPerThread')
60 82
61 83
62 84 class Thread(models.Model):
63 85 objects = ThreadManager()
64 86
65 87 class Meta:
66 88 app_label = 'boards'
67 89
68 90 tags = models.ManyToManyField('Tag', related_name='thread_tags')
69 91 bump_time = models.DateTimeField(db_index=True)
70 92 last_edit_time = models.DateTimeField()
71 93 archived = models.BooleanField(default=False)
72 94 bumpable = models.BooleanField(default=True)
73 95 max_posts = models.IntegerField(default=get_thread_max_posts)
74 96
75 97 def get_tags(self) -> QuerySet:
76 98 """
77 99 Gets a sorted tag list.
78 100 """
79 101
80 102 return self.tags.order_by('name')
81 103
82 104 def bump(self):
83 105 """
84 106 Bumps (moves to up) thread if possible.
85 107 """
86 108
87 109 if self.can_bump():
88 110 self.bump_time = self.last_edit_time
89 111
90 112 self.update_bump_status()
91 113
92 114 logger.info('Bumped thread %d' % self.id)
93 115
94 116 def has_post_limit(self) -> bool:
95 117 return self.max_posts > 0
96 118
97 119 def update_bump_status(self, exclude_posts=None):
98 120 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
99 121 self.bumpable = False
100 122 self.update_posts_time(exclude_posts=exclude_posts)
101 123
102 124 def _get_cache_key(self):
103 125 return [datetime_to_epoch(self.last_edit_time)]
104 126
105 127 @cached_result(key_method=_get_cache_key)
106 128 def get_reply_count(self) -> int:
107 129 return self.get_replies().count()
108 130
109 131 @cached_result(key_method=_get_cache_key)
110 132 def get_images_count(self) -> int:
111 133 return self.get_replies().annotate(images_count=Count(
112 134 'images')).aggregate(Sum('images_count'))['images_count__sum']
113 135
114 136 def can_bump(self) -> bool:
115 137 """
116 138 Checks if the thread can be bumped by replying to it.
117 139 """
118 140
119 return self.bumpable and not self.archived
141 return self.bumpable and not self.is_archived()
120 142
121 143 def get_last_replies(self) -> QuerySet:
122 144 """
123 145 Gets several last replies, not including opening post
124 146 """
125 147
126 148 last_replies_count = settings.get_int('View', 'LastRepliesCount')
127 149
128 150 if last_replies_count > 0:
129 151 reply_count = self.get_reply_count()
130 152
131 153 if reply_count > 0:
132 154 reply_count_to_show = min(last_replies_count,
133 155 reply_count - 1)
134 156 replies = self.get_replies()
135 157 last_replies = replies[reply_count - reply_count_to_show:]
136 158
137 159 return last_replies
138 160
139 161 def get_skipped_replies_count(self) -> int:
140 162 """
141 163 Gets number of posts between opening post and last replies.
142 164 """
143 165 reply_count = self.get_reply_count()
144 166 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
145 167 reply_count - 1)
146 168 return reply_count - last_replies_count - 1
147 169
148 170 def get_replies(self, view_fields_only=False) -> QuerySet:
149 171 """
150 172 Gets sorted thread posts
151 173 """
152 174
153 query = Post.objects.filter(threads__in=[self])
154 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
175 query = self.multi_replies.order_by('pub_time').prefetch_related(
176 'images', 'thread', 'threads', 'attachments')
155 177 if view_fields_only:
156 178 query = query.defer('poster_ip')
157 179 return query.all()
158 180
159 181 def get_top_level_replies(self) -> QuerySet:
160 182 return self.get_replies().exclude(refposts__threads__in=[self])
161 183
162 184 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
163 185 """
164 186 Gets replies that have at least one image attached
165 187 """
166 188
167 189 return self.get_replies(view_fields_only).annotate(images_count=Count(
168 190 'images')).filter(images_count__gt=0)
169 191
170 192 def get_opening_post(self, only_id=False) -> Post:
171 193 """
172 194 Gets the first post of the thread
173 195 """
174 196
175 197 query = self.get_replies().order_by('pub_time')
176 198 if only_id:
177 199 query = query.only('id')
178 200 opening_post = query.first()
179 201
180 202 return opening_post
181 203
182 204 @cached_result()
183 205 def get_opening_post_id(self) -> int:
184 206 """
185 207 Gets ID of the first thread post.
186 208 """
187 209
188 210 return self.get_opening_post(only_id=True).id
189 211
190 212 def get_pub_time(self):
191 213 """
192 214 Gets opening post's pub time because thread does not have its own one.
193 215 """
194 216
195 217 return self.get_opening_post().pub_time
196 218
197 219 def __str__(self):
198 220 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
199 221
200 222 def get_tag_url_list(self) -> list:
201 223 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
202 224
203 225 def update_posts_time(self, exclude_posts=None):
204 226 last_edit_time = self.last_edit_time
205 227
206 for post in self.post_set.all():
228 for post in self.multi_replies.all():
207 229 if exclude_posts is None or post not in exclude_posts:
208 230 # Manual update is required because uids are generated on save
209 231 post.last_edit_time = last_edit_time
210 232 post.save(update_fields=['last_edit_time'])
211 233
212 234 post.get_threads().update(last_edit_time=last_edit_time)
213 235
214 236 def notify_clients(self):
215 237 if not settings.get_bool('External', 'WebsocketsEnabled'):
216 238 return
217 239
218 240 client = Client()
219 241
220 242 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
221 243 client.publish(channel_name, {
222 244 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
223 245 })
224 246 client.send()
225 247
226 248 def get_absolute_url(self):
227 249 return self.get_opening_post().get_absolute_url()
228 250
229 251 def get_required_tags(self):
230 252 return self.get_tags().filter(required=True)
253
254 def get_replies_newer(self, post_id):
255 return self.get_replies().filter(id__gt=post_id)
256
257 def is_archived(self):
258 return self.archived
@@ -1,136 +1,145 b''
1 1 .ui-button {
2 2 display: none;
3 3 }
4 4
5 5 .ui-dialog-content {
6 6 padding: 0;
7 7 min-height: 0;
8 8 }
9 9
10 10 .mark_btn {
11 11 cursor: pointer;
12 12 }
13 13
14 14 .img-full {
15 15 position: fixed;
16 16 background-color: #CCC;
17 17 border: 1px solid #000;
18 18 cursor: pointer;
19 19 }
20 20
21 21 .strikethrough {
22 22 text-decoration: line-through;
23 23 }
24 24
25 25 .post_preview {
26 26 z-index: 300;
27 27 position:absolute;
28 28 }
29 29
30 30 .gallery_image {
31 31 display: inline-block;
32 32 }
33 33
34 34 @media print {
35 35 .post-form-w {
36 36 display: none;
37 37 }
38 38 }
39 39
40 40 input[name="image"] {
41 41 display: block;
42 42 width: 100px;
43 43 height: 100px;
44 44 cursor: pointer;
45 45 position: absolute;
46 46 opacity: 0;
47 47 z-index: 1;
48 48 }
49 49
50 50 .file_wrap {
51 51 width: 100px;
52 52 height: 100px;
53 53 border: solid 1px white;
54 54 display: inline-block;
55 55 }
56 56
57 57 form > .file_wrap {
58 58 float: left;
59 59 }
60 60
61 61 .file-thumb {
62 62 width: 100px;
63 63 height: 100px;
64 64 background-size: cover;
65 65 background-position: center;
66 66 }
67 67
68 68 .compact-form-text {
69 69 margin-left:110px;
70 70 }
71 71
72 72 textarea, input {
73 73 -moz-box-sizing: border-box;
74 74 -webkit-box-sizing: border-box;
75 75 box-sizing: border-box;
76 76 }
77 77
78 78 .compact-form-text > textarea {
79 79 height: 100px;
80 80 width: 100%;
81 81 }
82 82
83 83 .post-button-form {
84 84 display: inline;
85 85 }
86 86
87 87 .post-button-form > button, #autoupdate {
88 88 border: none;
89 89 margin: inherit;
90 90 padding: inherit;
91 91 background: none;
92 92 font-size: inherit;
93 93 }
94 94
95 95 #form-close-button {
96 96 display: none;
97 97 }
98 98
99 99 .post-image-full {
100 100 width: 100%;
101 101 height: auto;
102 102 }
103 103
104 104 #preview-text {
105 105 display: none;
106 106 }
107 107
108 108 .random-images-table {
109 109 text-align: center;
110 110 width: 100%;
111 111 }
112 112
113 113 .random-images-table > div {
114 114 margin-left: auto;
115 115 margin-right: auto;
116 116 }
117 117
118 118 .tag-image, .tag-text-data {
119 119 display: inline-block;
120 120 }
121 121
122 122 .tag-text-data > h2 {
123 123 margin: 0;
124 124 }
125 125
126 126 .tag-image {
127 127 margin-right: 5px;
128 128 }
129 129
130 130 .reply-to-message {
131 131 display: none;
132 132 }
133 133
134 134 .tripcode {
135 135 padding: 2px;
136 }
137
138 #fav-panel {
139 display: none;
140 margin: 1ex;
141 }
142
143 #new-fav-post-count {
144 display: none;
136 145 } No newline at end of file
@@ -1,559 +1,579 b''
1 1 * {
2 2 text-decoration: none;
3 3 font-weight: inherit;
4 4 }
5 5
6 6 b, strong {
7 7 font-weight: bold;
8 8 }
9 9
10 10 html {
11 11 background: #555;
12 12 color: #ffffff;
13 13 }
14 14
15 15 body {
16 16 margin: 0;
17 17 }
18 18
19 19 #admin_panel {
20 20 background: #FF0000;
21 21 color: #00FF00
22 22 }
23 23
24 24 .input_field_error {
25 25 color: #FF0000;
26 26 }
27 27
28 28 .title {
29 29 font-weight: bold;
30 30 color: #ffcc00;
31 31 }
32 32
33 33 .link, a {
34 34 color: #afdcec;
35 35 }
36 36
37 37 .block {
38 38 display: inline-block;
39 39 vertical-align: top;
40 40 }
41 41
42 42 .tag {
43 43 color: #FFD37D;
44 44 }
45 45
46 46 .post_id {
47 47 color: #fff380;
48 48 }
49 49
50 50 .post, .dead_post, .archive_post, #posts-table {
51 51 background: #333;
52 52 padding: 10px;
53 53 clear: left;
54 54 word-wrap: break-word;
55 55 border-top: 1px solid #777;
56 56 border-bottom: 1px solid #777;
57 57 }
58 58
59 59 .post + .post {
60 60 border-top: none;
61 61 }
62 62
63 63 .dead_post + .dead_post {
64 64 border-top: none;
65 65 }
66 66
67 67 .archive_post + .archive_post {
68 68 border-top: none;
69 69 }
70 70
71 71 .metadata {
72 72 padding-top: 5px;
73 73 margin-top: 10px;
74 74 border-top: solid 1px #666;
75 75 color: #ddd;
76 76 }
77 77
78 78 .navigation_panel, .tag_info {
79 79 background: #222;
80 80 margin-bottom: 5px;
81 81 margin-top: 5px;
82 82 padding: 10px;
83 83 border-bottom: solid 1px #888;
84 84 border-top: solid 1px #888;
85 85 color: #eee;
86 86 }
87 87
88 88 .navigation_panel .link:first-child {
89 89 border-right: 1px solid #fff;
90 90 font-weight: bold;
91 91 margin-right: 1ex;
92 92 padding-right: 1ex;
93 93 }
94 94
95 95 .navigation_panel .right-link {
96 96 border-left: 1px solid #fff;
97 97 border-right: none;
98 98 float: right;
99 99 margin-left: 1ex;
100 100 margin-right: 0;
101 101 padding-left: 1ex;
102 102 padding-right: 0;
103 103 }
104 104
105 105 .navigation_panel .link {
106 106 font-weight: bold;
107 107 }
108 108
109 109 .navigation_panel::after, .post::after {
110 110 clear: both;
111 111 content: ".";
112 112 display: block;
113 113 height: 0;
114 114 line-height: 0;
115 115 visibility: hidden;
116 116 }
117 117
118 .tag_info {
119 text-align: center;
120 }
121
122 .tag_info > .tag-text-data {
123 text-align: left;
124 }
125
118 126 .header {
119 127 border-bottom: solid 2px #ccc;
120 128 margin-bottom: 5px;
121 129 border-top: none;
122 130 margin-top: 0;
123 131 }
124 132
125 133 .footer {
126 134 border-top: solid 2px #ccc;
127 135 margin-top: 5px;
128 136 border-bottom: none;
129 137 margin-bottom: 0;
130 138 }
131 139
132 140 p, .br {
133 141 margin-top: .5em;
134 142 margin-bottom: .5em;
135 143 }
136 144
137 145 .post-form-w {
138 146 background: #333344;
139 147 border-top: solid 1px #888;
140 148 border-bottom: solid 1px #888;
141 149 color: #fff;
142 150 padding: 10px;
143 151 margin-bottom: 5px;
144 152 margin-top: 5px;
145 153 }
146 154
147 155 .form-row {
148 156 width: 100%;
149 157 display: table-row;
150 158 }
151 159
152 160 .form-label {
153 161 padding: .25em 1ex .25em 0;
154 162 vertical-align: top;
155 163 display: table-cell;
156 164 }
157 165
158 166 .form-input {
159 167 padding: .25em 0;
160 168 width: 100%;
161 169 display: table-cell;
162 170 }
163 171
164 172 .form-errors {
165 173 font-weight: bolder;
166 174 vertical-align: middle;
167 175 display: table-cell;
168 176 }
169 177
170 178 .post-form input:not([name="image"]):not([type="checkbox"]):not([type="submit"]), .post-form textarea, .post-form select {
171 179 background: #333;
172 180 color: #fff;
173 181 border: solid 1px;
174 182 padding: 0;
175 183 font: medium sans-serif;
176 184 width: 100%;
177 185 }
178 186
179 187 .post-form textarea {
180 188 resize: vertical;
181 189 }
182 190
183 191 .form-submit {
184 192 display: table;
185 193 margin-bottom: 1ex;
186 194 margin-top: 1ex;
187 195 }
188 196
189 197 .form-title {
190 198 font-weight: bold;
191 199 font-size: 2ex;
192 200 margin-bottom: 0.5ex;
193 201 }
194 202
195 203 input[type="submit"], button {
196 204 background: #222;
197 205 border: solid 2px #fff;
198 206 color: #fff;
199 207 padding: 0.5ex;
200 208 margin-right: 0.5ex;
201 209 }
202 210
203 211 input[type="submit"]:hover {
204 212 background: #060;
205 213 }
206 214
207 215 .form-submit > button:hover {
208 216 background: #006;
209 217 }
210 218
211 219 blockquote {
212 220 border-left: solid 2px;
213 221 padding-left: 5px;
214 222 color: #B1FB17;
215 223 margin: 0;
216 224 }
217 225
218 226 .post > .image {
219 227 float: left;
220 228 margin: 0 1ex .5ex 0;
221 229 min-width: 1px;
222 230 text-align: center;
223 231 display: table-row;
224 232 }
225 233
226 234 .post > .metadata {
227 235 clear: left;
228 236 }
229 237
230 238 .get {
231 239 font-weight: bold;
232 240 color: #d55;
233 241 }
234 242
235 243 * {
236 244 text-decoration: none;
237 245 }
238 246
239 247 .dead_post > .post-info {
240 248 font-style: italic;
241 249 }
242 250
243 251 .archive_post > .post-info {
244 252 text-decoration: line-through;
245 253 }
246 254
247 255 .mark_btn {
248 256 border: 1px solid;
249 257 padding: 2px 2ex;
250 258 display: inline-block;
251 259 margin: 0 5px 4px 0;
252 260 }
253 261
254 262 .mark_btn:hover {
255 263 background: #555;
256 264 }
257 265
258 266 .quote {
259 267 color: #92cf38;
260 268 font-style: italic;
261 269 }
262 270
263 271 .multiquote {
264 272 padding: 3px;
265 273 display: inline-block;
266 274 background: #222;
267 275 border-style: solid;
268 276 border-width: 1px 1px 1px 4px;
269 277 font-size: 0.9em;
270 278 }
271 279
272 280 .spoiler {
273 281 background: black;
274 282 color: black;
275 283 padding: 0 1ex 0 1ex;
276 284 }
277 285
278 286 .spoiler:hover {
279 287 color: #ddd;
280 288 }
281 289
282 290 .comment {
283 291 color: #eb2;
284 292 }
285 293
286 294 a:hover {
287 295 text-decoration: underline;
288 296 }
289 297
290 298 .last-replies {
291 299 margin-left: 3ex;
292 300 margin-right: 3ex;
293 301 border-left: solid 1px #777;
294 302 border-right: solid 1px #777;
295 303 }
296 304
297 305 .last-replies > .post:first-child {
298 306 border-top: none;
299 307 }
300 308
301 309 .thread {
302 310 margin-bottom: 3ex;
303 311 margin-top: 1ex;
304 312 }
305 313
306 314 .post:target {
307 315 border: solid 2px white;
308 316 }
309 317
310 318 pre{
311 319 white-space:pre-wrap
312 320 }
313 321
314 322 li {
315 323 list-style-position: inside;
316 324 }
317 325
318 326 .fancybox-skin {
319 327 position: relative;
320 328 background-color: #fff;
321 329 color: #ddd;
322 330 text-shadow: none;
323 331 }
324 332
325 333 .fancybox-image {
326 334 border: 1px solid black;
327 335 }
328 336
329 337 .image-mode-tab {
330 338 background: #444;
331 339 color: #eee;
332 340 margin-top: 5px;
333 341 padding: 5px;
334 342 border-top: 1px solid #888;
335 343 border-bottom: 1px solid #888;
336 344 }
337 345
338 346 .image-mode-tab > label {
339 347 margin: 0 1ex;
340 348 }
341 349
342 350 .image-mode-tab > label > input {
343 351 margin-right: .5ex;
344 352 }
345 353
346 354 #posts-table {
347 355 margin-top: 5px;
348 356 margin-bottom: 5px;
349 357 }
350 358
351 359 .tag_info > h2 {
352 360 margin: 0;
353 361 }
354 362
355 363 .post-info {
356 364 color: #ddd;
357 365 margin-bottom: 1ex;
358 366 }
359 367
360 368 .moderator_info {
361 369 color: #e99d41;
362 370 opacity: 0.4;
363 371 }
364 372
365 373 .moderator_info:hover {
366 374 opacity: 1;
367 375 }
368 376
369 377 .refmap {
370 378 font-size: 0.9em;
371 379 color: #ccc;
372 380 margin-top: 1em;
373 381 }
374 382
375 383 .fav {
376 384 color: yellow;
377 385 }
378 386
379 387 .not_fav {
380 388 color: #ccc;
381 389 }
382 390
383 391 .role {
384 392 text-decoration: underline;
385 393 }
386 394
387 395 .form-email {
388 396 display: none;
389 397 }
390 398
391 399 .bar-value {
392 400 background: rgba(50, 55, 164, 0.45);
393 401 font-size: 0.9em;
394 402 height: 1.5em;
395 403 }
396 404
397 405 .bar-bg {
398 406 position: relative;
399 407 border-top: solid 1px #888;
400 408 border-bottom: solid 1px #888;
401 409 margin-top: 5px;
402 410 overflow: hidden;
403 411 }
404 412
405 413 .bar-text {
406 414 padding: 2px;
407 415 position: absolute;
408 416 left: 0;
409 417 top: 0;
410 418 }
411 419
412 420 .page_link {
413 421 background: #444;
414 422 border-top: solid 1px #888;
415 423 border-bottom: solid 1px #888;
416 424 padding: 5px;
417 425 color: #eee;
418 426 font-size: 2ex;
427 margin-top: .5ex;
428 margin-bottom: .5ex;
419 429 }
420 430
421 431 .skipped_replies {
422 432 padding: 5px;
423 433 margin-left: 3ex;
424 434 margin-right: 3ex;
425 435 border-left: solid 1px #888;
426 436 border-right: solid 1px #888;
427 437 border-bottom: solid 1px #888;
428 438 background: #000;
429 439 }
430 440
431 441 .current_page {
432 442 padding: 2px;
433 443 background-color: #afdcec;
434 444 color: #000;
435 445 }
436 446
437 447 .current_mode {
438 448 font-weight: bold;
439 449 }
440 450
441 451 .gallery_image {
442 452 border: solid 1px;
443 453 margin: 0.5ex;
444 454 text-align: center;
455 padding: 1ex;
445 456 }
446 457
447 458 code {
448 459 border: dashed 1px #ccc;
449 460 background: #111;
450 461 padding: 2px;
451 462 font-size: 1.2em;
452 463 display: inline-block;
453 464 }
454 465
455 466 pre {
456 467 overflow: auto;
457 468 }
458 469
459 470 .img-full {
460 471 background: #222;
461 472 border: solid 1px white;
462 473 }
463 474
464 475 .tag_item {
465 476 display: inline-block;
466 477 }
467 478
468 479 #id_models li {
469 480 list-style: none;
470 481 }
471 482
472 483 #id_q {
473 484 margin-left: 1ex;
474 485 }
475 486
476 487 ul {
477 488 padding-left: 0px;
478 489 }
479 490
480 491 .quote-header {
481 492 border-bottom: 2px solid #ddd;
482 493 margin-bottom: 1ex;
483 494 padding-bottom: .5ex;
484 495 color: #ddd;
485 496 font-size: 1.2em;
486 497 }
487 498
488 499 .global-id {
489 500 font-weight: bolder;
490 501 opacity: .5;
491 502 }
492 503
493 504 /* Post */
494 505 .post > .message, .post > .image {
495 506 padding-left: 1em;
496 507 }
497 508
498 509 /* Reflink preview */
499 510 .post_preview {
500 511 border-left: 1px solid #777;
501 512 border-right: 1px solid #777;
502 513 max-width: 600px;
503 514 }
504 515
505 516 /* Code highlighter */
506 517 .hljs {
507 518 color: #fff;
508 519 background: #000;
509 520 display: inline-block;
510 521 }
511 522
512 523 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
513 524 color: #fff;
514 525 }
515 526
516 527 #up {
517 528 position: fixed;
518 529 bottom: 5px;
519 530 right: 5px;
520 531 border: 1px solid #777;
521 532 background: #000;
522 533 padding: 4px;
534 opacity: 0.3;
535 }
536
537 #up:hover {
538 opacity: 1;
523 539 }
524 540
525 541 .user-cast {
526 542 border: solid #ffffff 1px;
527 543 padding: .2ex;
528 544 background: #152154;
529 545 color: #fff;
530 546 }
531 547
532 548 .highlight {
533 549 background: #222;
534 550 }
535 551
536 552 .post-button-form > button:hover {
537 553 text-decoration: underline;
538 554 }
539 555
540 556 .tree_reply > .post {
541 557 margin-top: 1ex;
542 558 border-left: solid 1px #777;
543 559 padding-right: 0;
544 560 }
545 561
546 562 #preview-text {
547 563 border: solid 1px white;
548 564 margin: 1ex 0 1ex 0;
549 565 padding: 1ex;
550 566 }
551 567
552 568 .image-metadata {
553 569 font-style: italic;
554 570 font-size: 0.9em;
555 571 }
556 572
557 573 .tripcode {
558 574 color: white;
559 575 }
576
577 #fav-panel {
578 border: 1px solid white;
579 }
@@ -1,160 +1,181 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 6 Copyright (C) 2013 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 var IMAGE_POPUP_MARGIN = 10;
27
26 28
27 29 var IMAGE_VIEWERS = [
28 30 ['simple', new SimpleImageViewer()],
29 31 ['popup', new PopupImageViewer()]
30 32 ];
31 33
32 34 var FULL_IMG_CLASS = 'post-image-full';
33 35
36 var ATTR_SCALE = 'scale';
37
34 38
35 39 function ImageViewer() {}
36 40 ImageViewer.prototype.view = function (post) {};
37 41
38 42 function SimpleImageViewer() {}
39 43 SimpleImageViewer.prototype.view = function (post) {
40 44 var images = post.find('img');
41 45 images.toggle();
42 46
43 47 // When we first enlarge an image, a full image needs to be created
44 48 if (images.length == 1) {
45 49 var thumb = images.first();
46 50
47 51 var width = thumb.attr('data-width');
48 52 var height = thumb.attr('data-height');
49 53
50 54 if (width == null || height == null) {
51 55 width = '100%';
52 56 height = '100%';
53 57 }
54 58
55 59 var parent = images.first().parent();
56 60 var link = parent.attr('href');
57 61
58 62 var fullImg = $('<img />')
59 63 .addClass(FULL_IMG_CLASS)
60 64 .attr('src', link)
61 65 .attr('width', width)
62 66 .attr('height', height);
63 67
64 68 parent.append(fullImg);
65 69 }
66 70 };
67 71
68 72 function PopupImageViewer() {}
69 73 PopupImageViewer.prototype.view = function (post) {
70 var margin = 20; //..change
71
72 74 var el = post;
73 75 var thumb_id = 'full' + el.find('img').attr('alt');
74 76
75 77 var existingPopups = $('#' + thumb_id);
76 if(!existingPopups.length) {
78 if (!existingPopups.length) {
77 79 var imgElement= el.find('img');
78 80
79 var img_w = imgElement.attr('data-width');
80 var img_h = imgElement.attr('data-height');
81 var full_img_w = imgElement.attr('data-width');
82 var full_img_h = imgElement.attr('data-height');
81 83
82 84 var win = $(window);
83 85
84 86 var win_w = win.width();
85 87 var win_h = win.height();
86 //new image size
87 if (img_w > win_w) {
88 img_h = img_h * (win_w/img_w) - margin;
89 img_w = win_w - margin;
88
89 // New image size
90 var w_scale = 1;
91 var h_scale = 1;
92
93 var freeWidth = win_w - 2 * IMAGE_POPUP_MARGIN;
94 var freeHeight = win_h - 2 * IMAGE_POPUP_MARGIN;
95
96 if (full_img_w > freeWidth) {
97 w_scale = full_img_w / freeWidth;
90 98 }
91 if (img_h > win_h) {
92 img_w = img_w * (win_h/img_h) - margin;
93 img_h = win_h - margin;
99 if (full_img_h > freeHeight) {
100 h_scale = full_img_h / freeHeight;
94 101 }
95 102
103 var scale = Math.max(w_scale, h_scale)
104 var img_w = full_img_w / scale;
105 var img_h = full_img_h / scale;
106
107 var postNode = $(el);
108
96 109 var img_pv = new Image();
97 110 var newImage = $(img_pv);
98 111 newImage.addClass('img-full')
99 112 .attr('id', thumb_id)
100 .attr('src', $(el).attr('href'))
101 .appendTo($(el))
113 .attr('src', postNode.attr('href'))
114 .attr(ATTR_SCALE, scale)
115 .appendTo(postNode)
102 116 .css({
103 117 'width': img_w,
104 118 'height': img_h,
105 119 'left': (win_w - img_w) / 2,
106 120 'top': ((win_h - img_h) / 2)
107 121 })
108 122 //scaling preview
109 123 .mousewheel(function(event, delta) {
110 var cx = event.originalEvent.clientX,
111 cy = event.originalEvent.clientY,
112 i_w = parseFloat(newImage.width()),
113 i_h = parseFloat(newImage.height()),
114 newIW = i_w * (delta > 0 ? 1.25 : 0.8),
115 newIH = i_h * (delta > 0 ? 1.25 : 0.8);
124 var cx = event.originalEvent.clientX;
125 var cy = event.originalEvent.clientY;
126
127 var scale = newImage.attr(ATTR_SCALE) / (delta > 0 ? 1.25 : 0.8);
128
129 var oldWidth = newImage.width();
130 var oldHeight = newImage.height();
131
132 var newIW = full_img_w / scale;
133 var newIH = full_img_h / scale;
116 134
117 135 newImage.width(newIW);
118 136 newImage.height(newIH);
119 //set position
137 newImage.attr(ATTR_SCALE, scale);
138
139 // Set position
140 var oldPosition = newImage.position();
120 141 newImage.css({
121 left: parseInt(cx - (newIW/i_w) * (cx - parseInt($(img_pv).position().left, 10)), 10),
122 top: parseInt(cy - (newIH/i_h) * (cy - parseInt($(img_pv).position().top, 10)), 10)
142 left: parseInt(cx - (newIW/oldWidth) * (cx - parseInt(oldPosition.left, 10)), 10),
143 top: parseInt(cy - (newIH/oldHeight) * (cy - parseInt(oldPosition.top, 10)), 10)
123 144 });
124 145
125 146 return false;
126 147 }
127 148 )
128 149 .draggable({
129 150 addClasses: false,
130 151 stack: '.img-full'
131 152 });
132 153 } else {
133 154 existingPopups.remove();
134 155 }
135 156 };
136 157
137 158 function addImgPreview() {
138 159 var viewerName = $('body').attr('data-image-viewer');
139 160 var viewer = ImageViewer();
140 161 for (var i = 0; i < IMAGE_VIEWERS.length; i++) {
141 162 var item = IMAGE_VIEWERS[i];
142 163 if (item[0] === viewerName) {
143 164 viewer = item[1];
144 165 break;
145 166 }
146 167 }
147 168
148 169 //keybind
149 170 $(document).on('keyup.removepic', function(e) {
150 171 if(e.which === 27) {
151 172 $('.img-full').remove();
152 173 }
153 174 });
154 175
155 176 $('body').on('click', '.thumb', function() {
156 177 viewer.view($(this));
157 178
158 179 return false;
159 180 });
160 181 }
@@ -1,56 +1,126 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 6 Copyright (C) 2013 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 var FAV_POST_UPDATE_PERIOD = 10000;
27
26 28 /**
27 29 * An email is a hidden file to prevent spam bots from posting. It has to be
28 30 * hidden.
29 31 */
30 32 function hideEmailFromForm() {
31 33 $('.form-email').parent().parent().hide();
32 34 }
33 35
34 36 /**
35 37 * Highlight code blocks with code highlighter
36 38 */
37 39 function highlightCode(node) {
38 40 node.find('pre code').each(function(i, e) {
39 41 hljs.highlightBlock(e);
40 42 });
41 43 }
42 44
45 function updateFavPosts() {
46 var includePostBody = $('#fav-panel').is(":visible");
47 var url = '/api/new_posts/';
48 if (includePostBody) {
49 url += '?include_posts'
50 }
51 $.getJSON(url,
52 function(data) {
53 var allNewPostCount = 0;
54
55 if (includePostBody) {
56 var favoriteThreadPanel = $('#fav-panel');
57 favoriteThreadPanel.empty();
58 }
59
60 $.each(data, function (_, dict) {
61 var newPostCount = dict.new_post_count;
62 allNewPostCount += newPostCount;
63
64 if (includePostBody) {
65 var favThreadNode = $('<div class="post"></div>');
66 favThreadNode.append($(dict.post_url));
67 favThreadNode.append(' ');
68 favThreadNode.append($('<span class="title">' + dict.title + '</span>'));
69
70 if (newPostCount > 0) {
71 favThreadNode.append(' (<a href="' + dict.newest_post_link + '">+' + newPostCount + "</a>)");
72 }
73
74 favoriteThreadPanel.append(favThreadNode);
75
76 addRefLinkPreview(favThreadNode[0]);
77 }
78 });
79
80 var newPostCountNode = $('#new-fav-post-count');
81 if (allNewPostCount > 0) {
82 newPostCountNode.text('(+' + allNewPostCount + ')');
83 newPostCountNode.show();
84 } else {
85 newPostCountNode.hide();
86 }
87 }
88 );
89 }
90
91 function initFavPanel() {
92 updateFavPosts();
93
94 if ($('#fav-panel-btn').length > 0) {
95 setInterval(updateFavPosts, FAV_POST_UPDATE_PERIOD);
96 $('#fav-panel-btn').click(function() {
97 $('#fav-panel').toggle();
98 updateFavPosts();
99
100 return false;
101 });
102
103 $(document).on('keyup.removepic', function(e) {
104 if(e.which === 27) {
105 $('#fav-panel').hide();
106 }
107 });
108 }
109 }
110
43 111 $( document ).ready(function() {
44 112 hideEmailFromForm();
45 113
46 114 $("a[href='#top']").click(function() {
47 115 $("html, body").animate({ scrollTop: 0 }, "slow");
48 116 return false;
49 117 });
50 118
51 119 addImgPreview();
52 120
53 121 addRefLinkPreview();
54 122
55 123 highlightCode($(document));
124
125 initFavPanel();
56 126 });
@@ -1,456 +1,457 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 6 Copyright (C) 2013-2014 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 26 var CLASS_POST = '.post'
27 27
28 28 var POST_ADDED = 0;
29 29 var POST_UPDATED = 1;
30 30
31 31 var JS_AUTOUPDATE_PERIOD = 20000;
32 32
33 33 var ALLOWED_FOR_PARTIAL_UPDATE = [
34 34 'refmap',
35 35 'post-info'
36 36 ];
37 37
38 38 var ATTR_CLASS = 'class';
39 39 var ATTR_UID = 'data-uid';
40 40
41 41 var wsUser = '';
42 42
43 43 var unreadPosts = 0;
44 44 var documentOriginalTitle = '';
45 45
46 46 // Thread ID does not change, can be stored one time
47 47 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
48 48
49 49 /**
50 50 * Connect to websocket server and subscribe to thread updates. On any update we
51 51 * request a thread diff.
52 52 *
53 53 * @returns {boolean} true if connected, false otherwise
54 54 */
55 55 function connectWebsocket() {
56 56 var metapanel = $('.metapanel')[0];
57 57
58 58 var wsHost = metapanel.getAttribute('data-ws-host');
59 59 var wsPort = metapanel.getAttribute('data-ws-port');
60 60
61 61 if (wsHost.length > 0 && wsPort.length > 0) {
62 62 var centrifuge = new Centrifuge({
63 63 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
64 64 "project": metapanel.getAttribute('data-ws-project'),
65 65 "user": wsUser,
66 66 "timestamp": metapanel.getAttribute('data-ws-token-time'),
67 67 "token": metapanel.getAttribute('data-ws-token'),
68 68 "debug": false
69 69 });
70 70
71 71 centrifuge.on('error', function(error_message) {
72 72 console.log("Error connecting to websocket server.");
73 73 console.log(error_message);
74 74 console.log("Using javascript update instead.");
75 75
76 76 // If websockets don't work, enable JS update instead
77 77 enableJsUpdate()
78 78 });
79 79
80 80 centrifuge.on('connect', function() {
81 81 var channelName = 'thread:' + threadId;
82 82 centrifuge.subscribe(channelName, function(message) {
83 83 getThreadDiff();
84 84 });
85 85
86 86 // For the case we closed the browser and missed some updates
87 87 getThreadDiff();
88 88 $('#autoupdate').hide();
89 89 });
90 90
91 91 centrifuge.connect();
92 92
93 93 return true;
94 94 } else {
95 95 return false;
96 96 }
97 97 }
98 98
99 99 /**
100 100 * Get diff of the posts from the current thread timestamp.
101 101 * This is required if the browser was closed and some post updates were
102 102 * missed.
103 103 */
104 104 function getThreadDiff() {
105 var lastUpdateTime = $('.metapanel').attr('data-last-update');
106 var lastPostId = $('.post').last().attr('id');
105 var all_posts = $('.post');
107 106
108 107 var uids = '';
109 var posts = $('.post');
108 var posts = all_posts;
110 109 for (var i = 0; i < posts.length; i++) {
111 110 uids += posts[i].getAttribute('data-uid') + ' ';
112 111 }
113 112
114 113 var data = {
115 114 uids: uids,
116 115 thread: threadId
117 }
116 };
118 117
119 118 var diffUrl = '/api/diff_thread/';
120 119
121 120 $.post(diffUrl,
122 121 data,
123 122 function(data) {
124 123 var updatedPosts = data.updated;
125 124 var addedPostCount = 0;
126 125
127 126 for (var i = 0; i < updatedPosts.length; i++) {
128 127 var postText = updatedPosts[i];
129 128 var post = $(postText);
130 129
131 130 if (updatePost(post) == POST_ADDED) {
132 131 addedPostCount++;
133 132 }
134 133 }
135 134
136 135 var hasMetaUpdates = updatedPosts.length > 0;
137 136 if (hasMetaUpdates) {
138 137 updateMetadataPanel();
139 138 }
140 139
141 140 if (addedPostCount > 0) {
142 141 updateBumplimitProgress(addedPostCount);
143 142 }
144 143
145 144 if (updatedPosts.length > 0) {
146 145 showNewPostsTitle(addedPostCount);
147 146 }
148 147
149 148 // TODO Process removed posts if any
150 149 $('.metapanel').attr('data-last-update', data.last_update);
151 150 },
152 151 'json'
153 152 )
154 153 }
155 154
156 155 /**
157 156 * Add or update the post on html page.
158 157 */
159 158 function updatePost(postHtml) {
160 159 // This needs to be set on start because the page is scrolled after posts
161 160 // are added or updated
162 161 var bottom = isPageBottom();
163 162
164 163 var post = $(postHtml);
165 164
166 165 var threadBlock = $('div.thread');
167 166
168 167 var postId = post.attr('id');
169 168
170 169 // If the post already exists, replace it. Otherwise add as a new one.
171 170 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
172 171
173 172 var type;
174 173
175 174 if (existingPosts.size() > 0) {
176 175 replacePartial(existingPosts.first(), post, false);
177 176 post = existingPosts.first();
178 177
179 178 type = POST_UPDATED;
180 179 } else {
181 180 post.appendTo(threadBlock);
182 181
183 182 if (bottom) {
184 183 scrollToBottom();
185 184 }
186 185
187 186 type = POST_ADDED;
188 187 }
189 188
190 189 processNewPost(post);
191 190
192 191 return type;
193 192 }
194 193
195 194 /**
196 195 * Initiate a blinking animation on a node to show it was updated.
197 196 */
198 197 function blink(node) {
199 198 var blinkCount = 2;
200 199
201 200 var nodeToAnimate = node;
202 201 for (var i = 0; i < blinkCount; i++) {
203 202 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
204 203 }
205 204 }
206 205
207 206 function isPageBottom() {
208 207 var scroll = $(window).scrollTop() / ($(document).height()
209 208 - $(window).height());
210 209
211 210 return scroll == 1
212 211 }
213 212
214 213 function enableJsUpdate() {
215 214 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
216 215 return true;
217 216 }
218 217
219 218 function initAutoupdate() {
220 219 if (location.protocol === 'https:') {
221 220 return enableJsUpdate();
222 221 } else {
223 222 if (connectWebsocket()) {
224 223 return true;
225 224 } else {
226 225 return enableJsUpdate();
227 226 }
228 227 }
229 228 }
230 229
231 230 function getReplyCount() {
232 231 return $('.thread').children(CLASS_POST).length
233 232 }
234 233
235 234 function getImageCount() {
236 235 return $('.thread').find('img').length
237 236 }
238 237
239 238 /**
240 239 * Update post count, images count and last update time in the metadata
241 240 * panel.
242 241 */
243 242 function updateMetadataPanel() {
244 243 var replyCountField = $('#reply-count');
245 244 var imageCountField = $('#image-count');
246 245
247 replyCountField.text(getReplyCount());
248 imageCountField.text(getImageCount());
246 var replyCount = getReplyCount();
247 replyCountField.text(replyCount);
248 var imageCount = getImageCount();
249 imageCountField.text(imageCount);
249 250
250 251 var lastUpdate = $('.post:last').children('.post-info').first()
251 252 .children('.pub_time').first().html();
252 253 if (lastUpdate !== '') {
253 254 var lastUpdateField = $('#last-update');
254 255 lastUpdateField.html(lastUpdate);
255 256 blink(lastUpdateField);
256 257 }
257 258
258 259 blink(replyCountField);
259 260 blink(imageCountField);
261
262 $('#message-count-text').text(ngettext('message', 'messages', replyCount));
263 $('#image-count-text').text(ngettext('image', 'images', imageCount));
260 264 }
261 265
262 266 /**
263 267 * Update bumplimit progress bar
264 268 */
265 269 function updateBumplimitProgress(postDelta) {
266 270 var progressBar = $('#bumplimit_progress');
267 271 if (progressBar) {
268 272 var postsToLimitElement = $('#left_to_limit');
269 273
270 274 var oldPostsToLimit = parseInt(postsToLimitElement.text());
271 275 var postCount = getReplyCount();
272 276 var bumplimit = postCount - postDelta + oldPostsToLimit;
273 277
274 278 var newPostsToLimit = bumplimit - postCount;
275 279 if (newPostsToLimit <= 0) {
276 280 $('.bar-bg').remove();
277 281 } else {
278 282 postsToLimitElement.text(newPostsToLimit);
279 283 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
280 284 }
281 285 }
282 286 }
283 287
284 288 /**
285 289 * Show 'new posts' text in the title if the document is not visible to a user
286 290 */
287 291 function showNewPostsTitle(newPostCount) {
288 292 if (document.hidden) {
289 293 if (documentOriginalTitle === '') {
290 294 documentOriginalTitle = document.title;
291 295 }
292 296 unreadPosts = unreadPosts + newPostCount;
293 297
294 298 var newTitle = '* ';
295 299 if (unreadPosts > 0) {
296 300 newTitle += '[' + unreadPosts + '] ';
297 301 }
298 302 newTitle += documentOriginalTitle;
299 303
300 304 document.title = newTitle;
301 305
302 306 document.addEventListener('visibilitychange', function() {
303 307 if (documentOriginalTitle !== '') {
304 308 document.title = documentOriginalTitle;
305 309 documentOriginalTitle = '';
306 310 unreadPosts = 0;
307 311 }
308 312
309 313 document.removeEventListener('visibilitychange', null);
310 314 });
311 315 }
312 316 }
313 317
314 318 /**
315 319 * Clear all entered values in the form fields
316 320 */
317 321 function resetForm(form) {
318 322 form.find('input:text, input:password, input:file, select, textarea').val('');
319 323 form.find('input:radio, input:checkbox')
320 324 .removeAttr('checked').removeAttr('selected');
321 325 $('.file_wrap').find('.file-thumb').remove();
322 326 $('#preview-text').hide();
323 327 }
324 328
325 329 /**
326 330 * When the form is posted, this method will be run as a callback
327 331 */
328 332 function updateOnPost(response, statusText, xhr, form) {
329 333 var json = $.parseJSON(response);
330 334 var status = json.status;
331 335
332 336 showAsErrors(form, '');
333 337
334 338 if (status === 'ok') {
335 339 resetFormPosition();
336 340 resetForm(form);
337 341 getThreadDiff();
338 342 scrollToBottom();
339 343 } else {
340 344 var errors = json.errors;
341 345 for (var i = 0; i < errors.length; i++) {
342 346 var fieldErrors = errors[i];
343 347
344 348 var error = fieldErrors.errors;
345 349
346 350 showAsErrors(form, error);
347 351 }
348 352 }
349 353 }
350 354
351 355 /**
352 356 * Show text in the errors row of the form.
353 357 * @param form
354 358 * @param text
355 359 */
356 360 function showAsErrors(form, text) {
357 361 form.children('.form-errors').remove();
358 362
359 363 if (text.length > 0) {
360 364 var errorList = $('<div class="form-errors">' + text + '<div>');
361 365 errorList.appendTo(form);
362 366 }
363 367 }
364 368
365 369 /**
366 370 * Run js methods that are usually run on the document, on the new post
367 371 */
368 372 function processNewPost(post) {
369 373 addRefLinkPreview(post[0]);
370 374 highlightCode(post);
371 375 blink(post);
372 376 }
373 377
374 378 function replacePartial(oldNode, newNode, recursive) {
375 379 if (!equalNodes(oldNode, newNode)) {
376 380 // Update parent node attributes
377 381 updateNodeAttr(oldNode, newNode, ATTR_CLASS);
378 382 updateNodeAttr(oldNode, newNode, ATTR_UID);
379 383
380 384 // Replace children
381 385 var children = oldNode.children();
382 386 if (children.length == 0) {
383 console.log(oldContent);
384 console.log(newContent)
385
386 387 oldNode.replaceWith(newNode);
387 388 } else {
388 389 var newChildren = newNode.children();
389 390 newChildren.each(function(i) {
390 391 var newChild = newChildren.eq(i);
391 392 var newChildClass = newChild.attr(ATTR_CLASS);
392 393
393 394 // Update only certain allowed blocks (e.g. not images)
394 395 if (ALLOWED_FOR_PARTIAL_UPDATE.indexOf(newChildClass) > -1) {
395 396 var oldChild = oldNode.children('.' + newChildClass);
396 397
397 398 if (oldChild.length == 0) {
398 399 oldNode.append(newChild);
399 400 } else {
400 401 if (!equalNodes(oldChild, newChild)) {
401 402 if (recursive) {
402 403 replacePartial(oldChild, newChild, false);
403 404 } else {
404 405 oldChild.replaceWith(newChild);
405 406 }
406 407 }
407 408 }
408 409 }
409 410 });
410 411 }
411 412 }
412 413 }
413 414
414 415 /**
415 416 * Compare nodes by content
416 417 */
417 418 function equalNodes(node1, node2) {
418 419 return node1[0].outerHTML == node2[0].outerHTML;
419 420 }
420 421
421 422 /**
422 423 * Update attribute of a node if it has changed
423 424 */
424 425 function updateNodeAttr(oldNode, newNode, attrName) {
425 426 var oldAttr = oldNode.attr(attrName);
426 427 var newAttr = newNode.attr(attrName);
427 428 if (oldAttr != newAttr) {
428 429 oldNode.attr(attrName, newAttr);
429 };
430 }
430 431 }
431 432
432 433 $(document).ready(function(){
433 434 if (initAutoupdate()) {
434 435 // Post form data over AJAX
435 436 var threadId = $('div.thread').children('.post').first().attr('id');
436 437
437 438 var form = $('#form');
438 439
439 440 if (form.length > 0) {
440 441 var options = {
441 442 beforeSubmit: function(arr, $form, options) {
442 showAsErrors($('form'), gettext('Sending message...'));
443 showAsErrors($('#form'), gettext('Sending message...'));
443 444 },
444 445 success: updateOnPost,
445 446 error: function() {
446 showAsErrors($('form'), gettext('Server error!'));
447 showAsErrors($('#form'), gettext('Server error!'));
447 448 },
448 449 url: '/api/add_post/' + threadId + '/'
449 450 };
450 451
451 452 form.ajaxForm(options);
452 453
453 454 resetForm(form);
454 455 }
455 456 }
456 457 });
@@ -1,186 +1,187 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load board %}
5 5 {% load static %}
6 6 {% load tz %}
7 7
8 8 {% block head %}
9 9 <meta name="robots" content="noindex">
10 10
11 11 {% if tag %}
12 12 <title>{{ tag.name }} - {{ site_name }}</title>
13 13 {% else %}
14 14 <title>{{ site_name }}</title>
15 15 {% endif %}
16 16
17 17 {% if prev_page_link %}
18 18 <link rel="prev" href="{{ prev_page_link }}" />
19 19 {% endif %}
20 20 {% if next_page_link %}
21 21 <link rel="next" href="{{ next_page_link }}" />
22 22 {% endif %}
23 23
24 24 {% endblock %}
25 25
26 26 {% block content %}
27 27
28 28 {% get_current_language as LANGUAGE_CODE %}
29 29 {% get_current_timezone as TIME_ZONE %}
30 30
31 31 {% for banner in banners %}
32 32 <div class="post">
33 33 <div class="title">{{ banner.title }}</div>
34 34 <div>{{ banner.text }}</div>
35 35 <div>{% trans 'Related message' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
36 36 </div>
37 37 {% endfor %}
38 38
39 39 {% if tag %}
40 <div class="tag_info">
40 <div class="tag_info" style="border-bottom: solid .5ex #{{ tag.get_color }}">
41 41 {% if random_image_post %}
42 42 <div class="tag-image">
43 43 {% with image=random_image_post.images.first %}
44 44 <a href="{{ random_image_post.get_absolute_url }}"><img
45 45 src="{{ image.image.url_200x150 }}"
46 46 width="{{ image.pre_width }}"
47 47 height="{{ image.pre_height }}"/></a>
48 48 {% endwith %}
49 49 </div>
50 50 {% endif %}
51 51 <div class="tag-text-data">
52 52 <h2>
53 53 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
54 54 {% if is_favorite %}
55 55 <button name="method" value="unsubscribe" class="fav">β˜…</button>
56 56 {% else %}
57 57 <button name="method" value="subscribe" class="not_fav">β˜…</button>
58 58 {% endif %}
59 59 </form>
60 60 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
61 61 {% if is_hidden %}
62 62 <button name="method" value="unhide" class="fav">H</button>
63 63 {% else %}
64 64 <button name="method" value="hide" class="not_fav">H</button>
65 65 {% endif %}
66 66 </form>
67 67 {{ tag.get_view|safe }}
68 68 {% if moderator %}
69 69 <span class="moderator_info">| <a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></span>
70 70 {% endif %}
71 71 </h2>
72 72 {% if tag.get_description %}
73 73 <p>{{ tag.get_description|safe }}</p>
74 74 {% endif %}
75 75 <p>{% blocktrans with active_thread_count=tag.get_active_thread_count thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads ({{ active_thread_count}} active) and {{ post_count }} posts.{% endblocktrans %}</p>
76 {% if related_tags %}
77 <p>{% trans 'Related tags:' %}
78 {% for rel_tag in related_tags %}
79 {{ rel_tag.get_view|safe }}{% if not forloop.last %}, {% else %}.{% endif %}
80 {% endfor %}
76 {% if tag.get_parent %}
77 <p>
78 {% if tag.get_parent %}
79 {{ tag.get_parent.get_view|safe }} /
80 {% endif %}
81 {{ tag.get_view|safe }}
81 82 </p>
82 83 {% endif %}
83 84 </div>
84 85 </div>
85 86 {% endif %}
86 87
87 88 {% if threads %}
88 89 {% if prev_page_link %}
89 90 <div class="page_link">
90 91 <a href="{{ prev_page_link }}">{% trans "Previous page" %}</a>
91 92 </div>
92 93 {% endif %}
93 94
94 95 {% for thread in threads %}
95 96 <div class="thread">
96 {% post_view thread.get_opening_post moderator=moderator is_opening=True thread=thread truncated=True need_open_link=True %}
97 {% post_view thread.get_opening_post moderator=moderator thread=thread truncated=True need_open_link=True %}
97 98 {% if not thread.archived %}
98 99 {% with last_replies=thread.get_last_replies %}
99 100 {% if last_replies %}
100 101 {% with skipped_replies_count=thread.get_skipped_replies_count %}
101 102 {% if skipped_replies_count %}
102 103 <div class="skipped_replies">
103 104 <a href="{% url 'thread' thread.get_opening_post_id %}">
104 {% blocktrans with count=skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
105 {% blocktrans count count=skipped_replies_count %}Skipped {{ count }} reply. Open thread to see all replies.{% plural %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
105 106 </a>
106 107 </div>
107 108 {% endif %}
108 109 {% endwith %}
109 110 <div class="last-replies">
110 111 {% for post in last_replies %}
111 {% post_view post is_opening=False moderator=moderator truncated=True %}
112 {% post_view post moderator=moderator truncated=True %}
112 113 {% endfor %}
113 114 </div>
114 115 {% endif %}
115 116 {% endwith %}
116 117 {% endif %}
117 118 </div>
118 119 {% endfor %}
119 120
120 121 {% if next_page_link %}
121 122 <div class="page_link">
122 123 <a href="{{ next_page_link }}">{% trans "Next page" %}</a>
123 124 </div>
124 125 {% endif %}
125 126 {% else %}
126 127 <div class="post">
127 128 {% trans 'No threads exist. Create the first one!' %}</div>
128 129 {% endif %}
129 130
130 131 <div class="post-form-w">
131 132 <script src="{% static 'js/panel.js' %}"></script>
132 133 <div class="post-form">
133 134 <div class="form-title">{% trans "Create new thread" %}</div>
134 135 <div class="swappable-form-full">
135 136 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
136 137 {{ form.as_div }}
137 138 <div class="form-submit">
138 139 <input type="submit" value="{% trans "Post" %}"/>
139 140 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
140 141 </div>
141 142 </form>
142 143 </div>
143 144 <div>
144 145 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
145 146 </div>
146 147 <div id="preview-text"></div>
147 148 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
148 149 <div><a href="{% url "tags" "required" %}">{% trans 'Tags' %}</a></div>
149 150 </div>
150 151 </div>
151 152
152 153 <script src="{% static 'js/form.js' %}"></script>
153 154 <script src="{% static 'js/thread_create.js' %}"></script>
154 155
155 156 {% endblock %}
156 157
157 158 {% block metapanel %}
158 159
159 160 <span class="metapanel">
160 161 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
161 162 {% trans "Pages:" %}
162 163 [
163 164 {% with dividers=paginator.get_dividers %}
164 165 {% for page in paginator.get_divided_range %}
165 166 {% if page in dividers %}
166 167 …,
167 168 {% endif %}
168 169 <a
169 170 {% ifequal page current_page.number %}
170 171 class="current_page"
171 172 {% endifequal %}
172 173 href="
173 174 {% if tag %}
174 175 {% url "tag" tag_name=tag.name %}?page={{ page }}
175 176 {% else %}
176 177 {% url "index" %}?page={{ page }}
177 178 {% endif %}
178 179 ">{{ page }}</a>
179 180 {% if not forloop.last %},{% endif %}
180 181 {% endfor %}
181 182 {% endwith %}
182 183 ]
183 184 [<a href="rss/">RSS</a>]
184 185 </span>
185 186
186 187 {% endblock %}
@@ -1,74 +1,79 b''
1 1 {% load staticfiles %}
2 2 {% load i18n %}
3 3 {% load l10n %}
4 4 {% load static from staticfiles %}
5 5
6 6 <!DOCTYPE html>
7 7 <html>
8 8 <head>
9 9 <link rel="stylesheet" type="text/css" href="{% static 'css/base.css' %}" media="all"/>
10 10 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/highlight.css' %}" media="all"/>
11 11 <link rel="stylesheet" type="text/css" href="{% static 'css/3party/jquery-ui.min.css' %}" media="all"/>
12 12 <link rel="stylesheet" type="text/css" href="{% static theme_css %}" media="all"/>
13 13
14 14 <link rel="alternate" type="application/rss+xml" href="rss/" title="{% trans 'Feed' %}"/>
15 15
16 16 <link rel="icon" type="image/png"
17 17 href="{% static 'favicon.png' %}">
18 18
19 19 <meta name="viewport" content="width=device-width, initial-scale=1"/>
20 20 <meta charset="utf-8"/>
21 21
22 22 {% block head %}{% endblock %}
23 23 </head>
24 24 <body data-image-viewer="{{ image_viewer }}">
25 25 <script src="{% static 'js/jquery-2.0.1.min.js' %}"></script>
26 26 <script src="{% static 'js/3party/jquery-ui.min.js' %}"></script>
27 27 <script src="{% static 'js/jquery.mousewheel.js' %}"></script>
28 28 <script src="{% url 'js_info_dict' %}"></script>
29 29
30 30 <div class="navigation_panel header">
31 31 <a class="link" href="{% url 'index' %}">{% trans "All threads" %}</a>
32 32 {% if tags_str %}
33 33 {% autoescape off %}
34 34 {{ tags_str }},
35 35 {% endautoescape %}
36 36 {% else %}
37 37 {% trans 'Add tags' %} β†’
38 38 {% endif %}
39 39 <a href="{% url 'tags' 'required'%}" title="{% trans 'Tag management' %}">{% trans "tags" %}</a>,
40 40 <a href="{% url 'search' %}" title="{% trans 'Search' %}">{% trans 'search' %}</a>,
41 41 <a href="{% url 'feed' %}" title="{% trans 'Feed' %}">{% trans 'feed' %}</a>,
42 <a href="{% url 'random' %}" title="{% trans 'Random images' %}">{% trans 'random' %}</a>
42 <a href="{% url 'random' %}" title="{% trans 'Random images' %}">{% trans 'random' %}</a>{% if has_fav_threads %},
43
44 <a href="#" id="fav-panel-btn">{% trans 'favorites' %} <span id="new-fav-post-count"></span></a>
45 {% endif %}
43 46
44 47 {% if username %}
45 48 <a class="right-link link" href="{% url 'notifications' username %}" title="{% trans 'Notifications' %}">
46 49 {% trans 'Notifications' %}
47 50 {% ifnotequal new_notifications_count 0 %}
48 51 (<b>{{ new_notifications_count }}</b>)
49 52 {% endifnotequal %}
50 53 </a>
51 54 {% endif %}
52 55
53 56 <a class="right-link link" href="{% url 'settings' %}">{% trans 'Settings' %}</a>
54 57 </div>
55 58
59 <div id="fav-panel"><div class="post">{% trans "Loading..." %}</div></div>
60
56 61 {% block content %}{% endblock %}
57 62
58 63 <script src="{% static 'js/3party/highlight.min.js' %}"></script>
59 64 <script src="{% static 'js/popup.js' %}"></script>
60 65 <script src="{% static 'js/image.js' %}"></script>
61 66 <script src="{% static 'js/refpopup.js' %}"></script>
62 67 <script src="{% static 'js/main.js' %}"></script>
63 68
64 69 <div class="navigation_panel footer">
65 70 {% block metapanel %}{% endblock %}
66 71 [<a href="{% url 'admin:index' %}">{% trans 'Admin' %}</a>]
67 72 {% with ppd=posts_per_day|floatformat:2 %}
68 73 {% blocktrans %}Speed: {{ ppd }} posts per day{% endblocktrans %}
69 74 {% endwith %}
70 75 <a class="link" href="#top" id="up">{% trans 'Up' %}</a>
71 76 </div>
72 77
73 78 </body>
74 79 </html>
@@ -1,113 +1,109 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 7 <div class="post-info">
8 8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
9 9 <span class="title">{{ post.title }}</span>
10 10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 11 {% if post.tripcode %}
12 12 {% with tripcode=post.get_tripcode %}
13 13 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
14 14 class="tripcode" title="{{ tripcode.get_full_text }}"
15 15 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
16 16 {% endwith %}
17 17 {% endif %}
18 18 {% comment %}
19 19 Thread death time needs to be shown only if the thread is alredy archived
20 20 and this is an opening post (thread death time) or a post for popup
21 21 (we don't see OP here so we show the death time in the post itself).
22 22 {% endcomment %}
23 23 {% if thread.archived %}
24 24 {% if is_opening %}
25 25 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
26 26 {% endif %}
27 27 {% endif %}
28 28 {% if is_opening %}
29 29 {% if need_open_link %}
30 30 {% if thread.archived %}
31 31 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
32 32 {% else %}
33 33 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
34 34 {% endif %}
35 35 {% endif %}
36 36 {% else %}
37 37 {% if need_op_data %}
38 38 {% with thread.get_opening_post as op %}
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
39 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
40 40 {% endwith %}
41 41 {% endif %}
42 42 {% endif %}
43 43 {% if reply_link and not thread.archived %}
44 44 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
45 45 {% endif %}
46 46
47 47 {% if post.global_id %}
48 48 <a class="global-id" href="{% url 'post_sync_data' post.id %}"> [RAW] </a>
49 49 {% endif %}
50 50
51 51 {% if moderator %}
52 52 <span class="moderator_info">
53 53 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
54 54 {% if is_opening %}
55 55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
56 56 {% endif %}
57 57 </span>
58 58 {% endif %}
59 59 </div>
60 60 {% comment %}
61 61 Post images. Currently only 1 image can be posted and shown, but post model
62 62 supports multiple.
63 63 {% endcomment %}
64 {% if post.images.exists %}
65 {% with post.images.first as image %}
66 {{ image.get_view|safe }}
67 {% endwith %}
68 {% endif %}
69 {% if post.attachments.exists %}
70 {% with post.attachments.first as file %}
71 {{ file.get_view|safe }}
72 {% endwith %}
73 {% endif %}
64 {% for image in post.images.all %}
65 {{ image.get_view|safe }}
66 {% endfor %}
67 {% for file in post.attachments.all %}
68 {{ file.get_view|safe }}
69 {% endfor %}
74 70 {% comment %}
75 71 Post message (text)
76 72 {% endcomment %}
77 73 <div class="message">
78 74 {% autoescape off %}
79 75 {% if truncated %}
80 76 {{ post.get_text|truncatewords_html:50 }}
81 77 {% else %}
82 78 {{ post.get_text }}
83 79 {% endif %}
84 80 {% endautoescape %}
85 81 </div>
86 82 {% if post.is_referenced %}
87 83 {% if mode_tree %}
88 84 <div class="tree_reply">
89 85 {% for refpost in post.get_referenced_posts %}
90 86 {% post_view refpost mode_tree=True %}
91 87 {% endfor %}
92 88 </div>
93 89 {% else %}
94 90 <div class="refmap">
95 91 {% trans "Replies" %}: {{ post.refmap|safe }}
96 92 </div>
97 93 {% endif %}
98 94 {% endif %}
99 95 {% comment %}
100 96 Thread metadata: counters, tags etc
101 97 {% endcomment %}
102 98 {% if is_opening %}
103 99 <div class="metadata">
104 100 {% if is_opening and need_open_link %}
105 {{ thread.get_reply_count }} {% trans 'messages' %},
106 {{ thread.get_images_count }} {% trans 'images' %}.
101 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
102 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
107 103 {% endif %}
108 104 <span class="tags">
109 105 {{ thread.get_tag_url_list|safe }}
110 106 </span>
111 107 </div>
112 108 {% endif %}
113 109 </div>
@@ -1,40 +1,44 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load static from staticfiles %}
5 5 {% load board %}
6 6 {% load tz %}
7 7
8 8 {% block head %}
9 <title>{{ opening_post.get_title|striptags|truncatewords:10 }}
10 - {{ site_name }}</title>
9 <title>{{ opening_post.get_title_or_text }} - {{ site_name }}</title>
11 10 {% endblock %}
12 11
13 12 {% block content %}
14 13 <div class="image-mode-tab">
15 14 <a {% ifequal mode 'normal' %}class="current_mode"{% endifequal %} href="{% url 'thread' opening_post.id %}">{% trans 'Normal' %}</a>,
16 15 <a {% ifequal mode 'gallery' %}class="current_mode"{% endifequal %} href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery' %}</a>,
17 16 <a {% ifequal mode 'tree' %}class="current_mode"{% endifequal %} href="{% url 'thread_tree' opening_post.id %}">{% trans 'Tree' %}</a>
18 17 </div>
19 18
20 19 {% block thread_content %}
21 20 {% endblock %}
22 21 {% endblock %}
23 22
24 23 {% block metapanel %}
25 24
26 25 <span class="metapanel"
27 26 data-last-update="{{ last_update }}"
28 27 data-ws-token-time="{{ ws_token_time }}"
29 28 data-ws-token="{{ ws_token }}"
30 29 data-ws-project="{{ ws_project }}"
31 30 data-ws-host="{{ ws_host }}"
32 31 data-ws-port="{{ ws_port }}">
33 32
34 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %} {% trans 'messages' %},
35 <span id="image-count">{{ thread.get_images_count }}</span> {% trans 'images' %}.
33 {% with replies_count=thread.get_reply_count%}
34 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %}
35 <span id="message-count-text">{% blocktrans count repliess_count=replies_count %}message{% plural %}messages{% endblocktrans %}</span>,
36 {% endwith %}
37 {% with images_count=thread.get_images_count%}
38 <span id="image-count">{{ images_count }}</span> <span id="image-count-text">{% blocktrans count count=images_count %}image{% plural %}images{% endblocktrans %}</span>.
39 {% endwith %}
36 40 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
37 41 [<a href="rss/">RSS</a>]
38 42 </span>
39 43
40 44 {% endblock %}
@@ -1,57 +1,70 b''
1 1 {% extends "boards/thread.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load static from staticfiles %}
5 5 {% load board %}
6 6 {% load tz %}
7 7
8 8 {% block thread_content %}
9 9 {% get_current_language as LANGUAGE_CODE %}
10 10 {% get_current_timezone as TIME_ZONE %}
11 11
12 <div class="tag_info">
13 <h2>
14 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
15 {% if is_favorite %}
16 <button name="method" value="unsubscribe" class="fav">β˜…</button>
17 {% else %}
18 <button name="method" value="subscribe" class="not_fav">β˜…</button>
19 {% endif %}
20 </form>
21 {{ opening_post.get_title_or_text }}
22 </h2>
23 </div>
24
12 25 {% if bumpable and thread.has_post_limit %}
13 26 <div class="bar-bg">
14 27 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
15 28 </div>
16 29 <div class="bar-text">
17 30 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
18 31 </div>
19 32 </div>
20 33 {% endif %}
21 34
22 35 <div class="thread">
23 36 {% for post in thread.get_replies %}
24 37 {% post_view post moderator=moderator reply_link=True %}
25 38 {% endfor %}
26 39 </div>
27 40
28 41 {% if not thread.archived %}
29 42 <div class="post-form-w">
30 43 <script src="{% static 'js/panel.js' %}"></script>
31 44 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
32 45 <div class="post-form" id="compact-form">
33 46 <div class="swappable-form-full">
34 47 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
35 48 <div class="compact-form-text"></div>
36 49 {{ form.as_div }}
37 50 <div class="form-submit">
38 51 <input type="submit" value="{% trans "Post" %}"/>
39 52 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
40 53 </div>
41 54 </form>
42 55 </div>
43 56 <div id="preview-text"></div>
44 57 <div><a href="{% url "staticpage" name="help" %}">
45 58 {% trans 'Text syntax' %}</a></div>
46 59 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
47 60 </div>
48 61 </div>
49 62
50 63 <script src="{% static 'js/jquery.form.min.js' %}"></script>
51 64 {% endif %}
52 65
53 66 <script src="{% static 'js/form.js' %}"></script>
54 67 <script src="{% static 'js/thread.js' %}"></script>
55 68 <script src="{% static 'js/thread_update.js' %}"></script>
56 69 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
57 70 {% endblock %}
@@ -1,91 +1,92 b''
1 1 from django.conf.urls import patterns, url
2 2 from django.views.i18n import javascript_catalog
3 3
4 4 from boards import views
5 5 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
6 6 from boards.views import api, tag_threads, all_threads, \
7 7 settings, all_tags, feed
8 8 from boards.views.authors import AuthorsView
9 9 from boards.views.notifications import NotificationView
10 10 from boards.views.search import BoardSearchView
11 11 from boards.views.static import StaticPageView
12 12 from boards.views.preview import PostPreviewView
13 13 from boards.views.sync import get_post_sync_data, response_get, response_pull
14 14 from boards.views.random import RandomImageView
15 15
16 16
17 17 js_info_dict = {
18 18 'packages': ('boards',),
19 19 }
20 20
21 21 urlpatterns = patterns('',
22 22 # /boards/
23 23 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
24 24
25 25 # /boards/tag/tag_name/
26 26 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
27 27 name='tag'),
28 28
29 29 # /boards/thread/
30 30 url(r'^thread/(?P<post_id>\d+)/$', views.thread.NormalThreadView.as_view(),
31 31 name='thread'),
32 32 url(r'^thread/(?P<post_id>\d+)/mode/gallery/$', views.thread.GalleryThreadView.as_view(),
33 33 name='thread_gallery'),
34 34 url(r'^thread/(?P<post_id>\d+)/mode/tree/$', views.thread.TreeThreadView.as_view(),
35 35 name='thread_tree'),
36 36 # /feed/
37 37 url(r'^feed/$', views.feed.FeedView.as_view(), name='feed'),
38 38
39 39 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
40 40 url(r'^tags/(?P<query>\w+)?/?$', all_tags.AllTagsView.as_view(), name='tags'),
41 41 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
42 42
43 43 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
44 44 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
45 45 name='staticpage'),
46 46
47 47 url(r'^random/$', RandomImageView.as_view(), name='random'),
48 48
49 49 # RSS feeds
50 50 url(r'^rss/$', AllThreadsFeed()),
51 51 url(r'^page/(?P<page>\d+)/rss/$', AllThreadsFeed()),
52 52 url(r'^tag/(?P<tag_name>\w+)/rss/$', TagThreadsFeed()),
53 53 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/rss/$', TagThreadsFeed()),
54 54 url(r'^thread/(?P<post_id>\d+)/rss/$', ThreadPostsFeed()),
55 55
56 56 # i18n
57 57 url(r'^jsi18n/$', javascript_catalog, js_info_dict,
58 58 name='js_info_dict'),
59 59
60 60 # API
61 61 url(r'^api/post/(?P<post_id>\d+)/$', api.get_post, name="get_post"),
62 62 url(r'^api/diff_thread/$', api.api_get_threaddiff, name="get_thread_diff"),
63 63 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
64 64 name='get_threads'),
65 65 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
66 66 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
67 67 name='get_thread'),
68 68 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
69 69 name='add_post'),
70 70 url(r'^api/notifications/(?P<username>\w+)/$', api.api_get_notifications,
71 71 name='api_notifications'),
72 72 url(r'^api/preview/$', api.api_get_preview, name='preview'),
73 url(r'^api/new_posts/$', api.api_get_new_posts, name='new_posts'),
73 74
74 75 # Sync protocol API
75 76 url(r'^api/sync/pull/$', response_pull, name='api_sync_pull'),
76 77 url(r'^api/sync/get/$', response_get, name='api_sync_pull'),
77 78 # TODO 'get' request
78 79
79 80 # Search
80 81 url(r'^search/$', BoardSearchView.as_view(), name='search'),
81 82
82 83 # Notifications
83 84 url(r'^notifications/(?P<username>\w+)$', NotificationView.as_view(), name='notifications'),
84 85
85 86 # Post preview
86 87 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
87 88
88 89 url(r'^post_xml/(?P<post_id>\d+)$', get_post_sync_data,
89 90 name='post_sync_data'),
90 91
91 92 )
@@ -1,92 +1,103 b''
1 1 """
2 2 This module contains helper functions and helper classes.
3 3 """
4 4 import hashlib
5 5 import time
6 6 import hmac
7 7
8 8 from django.core.cache import cache
9 9 from django.db.models import Model
10 from django import forms
10 11
11 12 from django.utils import timezone
13 from django.utils.translation import ugettext_lazy as _
14 import boards
12 15
13 16 from neboard import settings
14 17
15 18
16 19 CACHE_KEY_DELIMITER = '_'
17 20 PERMISSION_MODERATE = 'moderation'
18 21
19 22 def get_client_ip(request):
20 23 x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
21 24 if x_forwarded_for:
22 25 ip = x_forwarded_for.split(',')[-1].strip()
23 26 else:
24 27 ip = request.META.get('REMOTE_ADDR')
25 28 return ip
26 29
27 30
28 31 # TODO The output format is not epoch because it includes microseconds
29 32 def datetime_to_epoch(datetime):
30 33 return int(time.mktime(timezone.localtime(
31 34 datetime,timezone.get_current_timezone()).timetuple())
32 35 * 1000000 + datetime.microsecond)
33 36
34 37
35 38 def get_websocket_token(user_id='', timestamp=''):
36 39 """
37 40 Create token to validate information provided by new connection.
38 41 """
39 42
40 43 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
41 44 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
42 45 sign.update(user_id.encode())
43 46 sign.update(timestamp.encode())
44 47 token = sign.hexdigest()
45 48
46 49 return token
47 50
48 51
49 52 def cached_result(key_method=None):
50 53 """
51 54 Caches method result in the Django's cache system, persisted by object name,
52 55 object name and model id if object is a Django model.
53 56 """
54 57 def _cached_result(function):
55 58 def inner_func(obj, *args, **kwargs):
56 59 # TODO Include method arguments to the cache key
57 60 cache_key_params = [obj.__class__.__name__, function.__name__]
58 61 if isinstance(obj, Model):
59 62 cache_key_params.append(str(obj.id))
60 63
61 64 if key_method is not None:
62 65 cache_key_params += [str(arg) for arg in key_method(obj)]
63 66
64 67 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
65 68
66 69 persisted_result = cache.get(cache_key)
67 70 if persisted_result is not None:
68 71 result = persisted_result
69 72 else:
70 73 result = function(obj, *args, **kwargs)
71 74 cache.set(cache_key, result)
72 75
73 76 return result
74 77
75 78 return inner_func
76 79 return _cached_result
77 80
78 81
79 82 def is_moderator(request):
80 83 try:
81 84 moderate = request.user.has_perm(PERMISSION_MODERATE)
82 85 except AttributeError:
83 86 moderate = False
84 87
85 88 return moderate
86 89
87 90
88 91 def get_file_hash(file) -> str:
89 92 md5 = hashlib.md5()
90 93 for chunk in file.chunks():
91 94 md5.update(chunk)
92 95 return md5.hexdigest()
96
97
98 def validate_file_size(size: int):
99 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
100 if size > max_size:
101 raise forms.ValidationError(
102 _('File must be less than %s bytes')
103 % str(max_size))
@@ -1,170 +1,150 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.core.files import File
3 3 from django.core.files.temp import NamedTemporaryFile
4 4 from django.core.paginator import EmptyPage
5 5 from django.db import transaction
6 6 from django.http import Http404
7 7 from django.shortcuts import render, redirect
8 8 import requests
9 9
10 10 from boards import utils, settings
11 11 from boards.abstracts.paginator import get_paginator
12 12 from boards.abstracts.settingsmanager import get_settings_manager
13 13 from boards.forms import ThreadForm, PlainErrorList
14 14 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
15 15 from boards.views.banned import BannedView
16 16 from boards.views.base import BaseBoardView, CONTEXT_FORM
17 17 from boards.views.posting_mixin import PostMixin
18 18
19 19
20 20 FORM_TAGS = 'tags'
21 21 FORM_TEXT = 'text'
22 22 FORM_TITLE = 'title'
23 23 FORM_IMAGE = 'image'
24 24 FORM_THREADS = 'threads'
25 25
26 26 TAG_DELIMITER = ' '
27 27
28 28 PARAMETER_CURRENT_PAGE = 'current_page'
29 29 PARAMETER_PAGINATOR = 'paginator'
30 30 PARAMETER_THREADS = 'threads'
31 31 PARAMETER_BANNERS = 'banners'
32 32
33 33 PARAMETER_PREV_LINK = 'prev_page_link'
34 34 PARAMETER_NEXT_LINK = 'next_page_link'
35 35
36 36 TEMPLATE = 'boards/all_threads.html'
37 37 DEFAULT_PAGE = 1
38 38
39 39
40 40 class AllThreadsView(PostMixin, BaseBoardView):
41 41
42 42 def __init__(self):
43 43 self.settings_manager = None
44 44 super(AllThreadsView, self).__init__()
45 45
46 46 def get(self, request, form: ThreadForm=None):
47 47 page = request.GET.get('page', DEFAULT_PAGE)
48 48
49 49 params = self.get_context_data(request=request)
50 50
51 51 if not form:
52 52 form = ThreadForm(error_class=PlainErrorList)
53 53
54 54 self.settings_manager = get_settings_manager(request)
55 55 paginator = get_paginator(self.get_threads(),
56 56 settings.get_int('View', 'ThreadsPerPage'))
57 57 paginator.current_page = int(page)
58 58
59 59 try:
60 60 threads = paginator.page(page).object_list
61 61 except EmptyPage:
62 62 raise Http404()
63 63
64 64 params[PARAMETER_THREADS] = threads
65 65 params[CONTEXT_FORM] = form
66 66 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
67 67
68 68 self.get_page_context(paginator, params, page)
69 69
70 70 return render(request, TEMPLATE, params)
71 71
72 72 def post(self, request):
73 73 form = ThreadForm(request.POST, request.FILES,
74 74 error_class=PlainErrorList)
75 75 form.session = request.session
76 76
77 77 if form.is_valid():
78 78 return self.create_thread(request, form)
79 79 if form.need_to_ban:
80 80 # Ban user because he is suspected to be a bot
81 81 self._ban_current_user(request)
82 82
83 83 return self.get(request, form)
84 84
85 85 def get_page_context(self, paginator, params, page):
86 86 """
87 87 Get pagination context variables
88 88 """
89 89
90 90 params[PARAMETER_PAGINATOR] = paginator
91 91 current_page = paginator.page(int(page))
92 92 params[PARAMETER_CURRENT_PAGE] = current_page
93 93 if current_page.has_previous():
94 94 params[PARAMETER_PREV_LINK] = self.get_previous_page_link(
95 95 current_page)
96 96 if current_page.has_next():
97 97 params[PARAMETER_NEXT_LINK] = self.get_next_page_link(current_page)
98 98
99 99 def get_previous_page_link(self, current_page):
100 100 return reverse('index') + '?page=' \
101 101 + str(current_page.previous_page_number())
102 102
103 103 def get_next_page_link(self, current_page):
104 104 return reverse('index') + '?page=' \
105 105 + str(current_page.next_page_number())
106 106
107 @staticmethod
108 def parse_tags_string(tag_strings):
109 """
110 Parses tag list string and returns tag object list.
111 """
112
113 tags = []
114
115 if tag_strings:
116 tag_strings = tag_strings.split(TAG_DELIMITER)
117 for tag_name in tag_strings:
118 tag_name = tag_name.strip().lower()
119 if len(tag_name) > 0:
120 tag, created = Tag.objects.get_or_create(name=tag_name)
121 tags.append(tag)
122
123 return tags
124
125 107 @transaction.atomic
126 108 def create_thread(self, request, form: ThreadForm, html_response=True):
127 109 """
128 110 Creates a new thread with an opening post.
129 111 """
130 112
131 113 ip = utils.get_client_ip(request)
132 114 is_banned = Ban.objects.filter(ip=ip).exists()
133 115
134 116 if is_banned:
135 117 if html_response:
136 118 return redirect(BannedView().as_view())
137 119 else:
138 120 return
139 121
140 122 data = form.cleaned_data
141 123
142 124 title = form.get_title()
143 125 text = data[FORM_TEXT]
144 126 file = form.get_file()
145 127 threads = data[FORM_THREADS]
146 128
147 129 text = self._remove_invalid_links(text)
148 130
149 tag_strings = data[FORM_TAGS]
150
151 tags = self.parse_tags_string(tag_strings)
131 tags = data[FORM_TAGS]
152 132
153 133 post = Post.objects.create_post(title=title, text=text, file=file,
154 134 ip=ip, tags=tags, opening_posts=threads,
155 135 tripcode=form.get_tripcode())
156 136
157 137 # This is required to update the threads to which posts we have replied
158 138 # when creating this one
159 139 post.notify_clients()
160 140
161 141 if html_response:
162 142 return redirect(post.get_absolute_url())
163 143
164 144 def get_threads(self):
165 145 """
166 146 Gets list of threads that will be shown on a page.
167 147 """
168 148
169 149 return Thread.objects.order_by('-bump_time')\
170 150 .exclude(tags__in=self.settings_manager.get_hidden_tags())
@@ -1,243 +1,296 b''
1 from collections import OrderedDict
1 2 import json
2 3 import logging
3 4
4 5 import xml.etree.ElementTree as ET
5 6
6 7 from django.db import transaction
8 from django.db.models import Count
7 9 from django.http import HttpResponse
8 10 from django.shortcuts import get_object_or_404
9 11 from django.core import serializers
12 from boards.abstracts.settingsmanager import get_settings_manager,\
13 FAV_THREAD_NO_UPDATES
10 14
11 15 from boards.forms import PostForm, PlainErrorList
12 16 from boards.models import Post, Thread, Tag, GlobalId
13 17 from boards.models.post.sync import SyncManager
14 18 from boards.utils import datetime_to_epoch
15 19 from boards.views.thread import ThreadView
16 20 from boards.models.user import Notification
17 21 from boards.mdx_neboard import Parser
18 22
19 23
20 24 __author__ = 'neko259'
21 25
22 26 PARAMETER_TRUNCATED = 'truncated'
23 27 PARAMETER_TAG = 'tag'
24 28 PARAMETER_OFFSET = 'offset'
25 29 PARAMETER_DIFF_TYPE = 'type'
26 30 PARAMETER_POST = 'post'
27 31 PARAMETER_UPDATED = 'updated'
28 32 PARAMETER_LAST_UPDATE = 'last_update'
29 33 PARAMETER_THREAD = 'thread'
30 34 PARAMETER_UIDS = 'uids'
31 35
32 36 DIFF_TYPE_HTML = 'html'
33 37 DIFF_TYPE_JSON = 'json'
34 38
35 39 STATUS_OK = 'ok'
36 40 STATUS_ERROR = 'error'
37 41
38 42 logger = logging.getLogger(__name__)
39 43
40 44
41 45 @transaction.atomic
42 46 def api_get_threaddiff(request):
43 47 """
44 48 Gets posts that were changed or added since time
45 49 """
46 50
47 51 thread_id = request.POST.get(PARAMETER_THREAD)
48 52 uids_str = request.POST.get(PARAMETER_UIDS).strip()
49 53 uids = uids_str.split(' ')
50 54
51 thread = get_object_or_404(Post, id=thread_id).get_thread()
55 opening_post = get_object_or_404(Post, id=thread_id)
56 thread = opening_post.get_thread()
52 57
53 58 json_data = {
54 59 PARAMETER_UPDATED: [],
55 60 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
56 61 }
57 62 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
58 63
59 64 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
60 65
61 66 for post in posts:
62 json_data[PARAMETER_UPDATED].append(get_post_data(post.id, diff_type,
63 request))
67 json_data[PARAMETER_UPDATED].append(post.get_post_data(
68 format_type=diff_type, request=request))
64 69 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
65 70
71 # If the tag is favorite, update the counter
72 settings_manager = get_settings_manager(request)
73 favorite = settings_manager.thread_is_fav(opening_post)
74 if favorite:
75 settings_manager.add_or_read_fav_thread(opening_post)
76
66 77 return HttpResponse(content=json.dumps(json_data))
67 78
68 79
69 80 def api_add_post(request, opening_post_id):
70 81 """
71 82 Adds a post and return the JSON response for it
72 83 """
73 84
74 85 opening_post = get_object_or_404(Post, id=opening_post_id)
75 86
76 87 logger.info('Adding post via api...')
77 88
78 89 status = STATUS_OK
79 90 errors = []
80 91
81 92 if request.method == 'POST':
82 93 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
83 94 form.session = request.session
84 95
85 96 if form.need_to_ban:
86 97 # Ban user because he is suspected to be a bot
87 98 # _ban_current_user(request)
88 99 status = STATUS_ERROR
89 100 if form.is_valid():
90 101 post = ThreadView().new_post(request, form, opening_post,
91 102 html_response=False)
92 103 if not post:
93 104 status = STATUS_ERROR
94 105 else:
95 106 logger.info('Added post #%d via api.' % post.id)
96 107 else:
97 108 status = STATUS_ERROR
98 109 errors = form.as_json_errors()
99 110
100 111 response = {
101 112 'status': status,
102 113 'errors': errors,
103 114 }
104 115
105 116 return HttpResponse(content=json.dumps(response))
106 117
107 118
108 119 def get_post(request, post_id):
109 120 """
110 121 Gets the html of a post. Used for popups. Post can be truncated if used
111 122 in threads list with 'truncated' get parameter.
112 123 """
113 124
114 125 post = get_object_or_404(Post, id=post_id)
115 126 truncated = PARAMETER_TRUNCATED in request.GET
116 127
117 128 return HttpResponse(content=post.get_view(truncated=truncated))
118 129
119 130
120 131 def api_get_threads(request, count):
121 132 """
122 133 Gets the JSON thread opening posts list.
123 134 Parameters that can be used for filtering:
124 135 tag, offset (from which thread to get results)
125 136 """
126 137
127 138 if PARAMETER_TAG in request.GET:
128 139 tag_name = request.GET[PARAMETER_TAG]
129 140 if tag_name is not None:
130 141 tag = get_object_or_404(Tag, name=tag_name)
131 142 threads = tag.get_threads().filter(archived=False)
132 143 else:
133 144 threads = Thread.objects.filter(archived=False)
134 145
135 146 if PARAMETER_OFFSET in request.GET:
136 147 offset = request.GET[PARAMETER_OFFSET]
137 148 offset = int(offset) if offset is not None else 0
138 149 else:
139 150 offset = 0
140 151
141 152 threads = threads.order_by('-bump_time')
142 153 threads = threads[offset:offset + int(count)]
143 154
144 155 opening_posts = []
145 156 for thread in threads:
146 157 opening_post = thread.get_opening_post()
147 158
148 159 # TODO Add tags, replies and images count
149 post_data = get_post_data(opening_post.id, include_last_update=True)
160 post_data = opening_post.get_post_data(include_last_update=True)
150 161 post_data['bumpable'] = thread.can_bump()
151 162 post_data['archived'] = thread.archived
152 163
153 164 opening_posts.append(post_data)
154 165
155 166 return HttpResponse(content=json.dumps(opening_posts))
156 167
157 168
158 169 # TODO Test this
159 170 def api_get_tags(request):
160 171 """
161 172 Gets all tags or user tags.
162 173 """
163 174
164 175 # TODO Get favorite tags for the given user ID
165 176
166 177 tags = Tag.objects.get_not_empty_tags()
167 178
168 179 term = request.GET.get('term')
169 180 if term is not None:
170 181 tags = tags.filter(name__contains=term)
171 182
172 183 tag_names = [tag.name for tag in tags]
173 184
174 185 return HttpResponse(content=json.dumps(tag_names))
175 186
176 187
177 188 # TODO The result can be cached by the thread last update time
178 189 # TODO Test this
179 190 def api_get_thread_posts(request, opening_post_id):
180 191 """
181 192 Gets the JSON array of thread posts
182 193 """
183 194
184 195 opening_post = get_object_or_404(Post, id=opening_post_id)
185 196 thread = opening_post.get_thread()
186 197 posts = thread.get_replies()
187 198
188 199 json_data = {
189 200 'posts': [],
190 201 'last_update': None,
191 202 }
192 203 json_post_list = []
193 204
194 205 for post in posts:
195 json_post_list.append(get_post_data(post.id))
206 json_post_list.append(post.get_post_data())
196 207 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
197 208 json_data['posts'] = json_post_list
198 209
199 210 return HttpResponse(content=json.dumps(json_data))
200 211
201 212
202 213 def api_get_notifications(request, username):
203 214 last_notification_id_str = request.GET.get('last', None)
204 215 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
205 216
206 217 posts = Notification.objects.get_notification_posts(username=username,
207 218 last=last_id)
208 219
209 220 json_post_list = []
210 221 for post in posts:
211 json_post_list.append(get_post_data(post.id))
222 json_post_list.append(post.get_post_data())
212 223 return HttpResponse(content=json.dumps(json_post_list))
213 224
214 225
215 226 def api_get_post(request, post_id):
216 227 """
217 228 Gets the JSON of a post. This can be
218 229 used as and API for external clients.
219 230 """
220 231
221 232 post = get_object_or_404(Post, id=post_id)
222 233
223 234 json = serializers.serialize("json", [post], fields=(
224 235 "pub_time", "_text_rendered", "title", "text", "image",
225 236 "image_width", "image_height", "replies", "tags"
226 237 ))
227 238
228 239 return HttpResponse(content=json)
229 240
230 241
231 # TODO Remove this method and use post method directly
232 def get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
233 include_last_update=False):
234 post = get_object_or_404(Post, id=post_id)
235 return post.get_post_data(format_type=format_type, request=request,
236 include_last_update=include_last_update)
237
238
239 242 def api_get_preview(request):
240 243 raw_text = request.POST['raw_text']
241 244
242 245 parser = Parser()
243 246 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
247
248
249 def api_get_new_posts(request):
250 """
251 Gets favorite threads and unread posts count.
252 """
253 posts = list()
254
255 include_posts = 'include_posts' in request.GET
256
257 settings_manager = get_settings_manager(request)
258 fav_threads = settings_manager.get_fav_threads()
259 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
260 .order_by('-pub_time').prefetch_related('thread')
261
262 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
263 if include_posts:
264 new_post_threads = Thread.objects.get_new_posts(ops)
265 if new_post_threads:
266 thread_ids = {thread.id: thread for thread in new_post_threads}
267 else:
268 thread_ids = dict()
269
270 for op in fav_thread_ops:
271 fav_thread_dict = dict()
272
273 op_thread = op.get_thread()
274 if op_thread.id in thread_ids:
275 thread = thread_ids[op_thread.id]
276 new_post_count = thread.new_post_count
277 fav_thread_dict['newest_post_link'] = thread.get_replies()\
278 .filter(id__gt=fav_threads[str(op.id)])\
279 .first().get_absolute_url()
280 else:
281 new_post_count = 0
282 fav_thread_dict['new_post_count'] = new_post_count
283
284 fav_thread_dict['id'] = op.id
285
286 fav_thread_dict['post_url'] = op.get_link_view()
287 fav_thread_dict['title'] = op.title
288
289 posts.append(fav_thread_dict)
290 else:
291 fav_thread_dict = dict()
292 fav_thread_dict['new_post_count'] = \
293 Thread.objects.get_new_post_count(ops)
294 posts.append(fav_thread_dict)
295
296 return HttpResponse(content=json.dumps(posts))
@@ -1,140 +1,163 b''
1 1 import hashlib
2 2 from django.core.exceptions import ObjectDoesNotExist
3 3 from django.http import Http404
4 4 from django.shortcuts import get_object_or_404, render, redirect
5 5 from django.views.generic.edit import FormMixin
6 6 from django.utils import timezone
7 7 from django.utils.dateformat import format
8 8
9 9 from boards import utils, settings
10 from boards.abstracts.settingsmanager import get_settings_manager
10 11 from boards.forms import PostForm, PlainErrorList
11 12 from boards.models import Post
12 13 from boards.views.base import BaseBoardView, CONTEXT_FORM
14 from boards.views.mixins import DispatcherMixin
13 15 from boards.views.posting_mixin import PostMixin
14 16
15 17 import neboard
16 18
17 19
18 20 CONTEXT_LASTUPDATE = "last_update"
19 21 CONTEXT_THREAD = 'thread'
20 22 CONTEXT_WS_TOKEN = 'ws_token'
21 23 CONTEXT_WS_PROJECT = 'ws_project'
22 24 CONTEXT_WS_HOST = 'ws_host'
23 25 CONTEXT_WS_PORT = 'ws_port'
24 26 CONTEXT_WS_TIME = 'ws_token_time'
25 27 CONTEXT_MODE = 'mode'
26 28 CONTEXT_OP = 'opening_post'
29 CONTEXT_FAVORITE = 'is_favorite'
27 30
28 31 FORM_TITLE = 'title'
29 32 FORM_TEXT = 'text'
30 33 FORM_IMAGE = 'image'
31 34 FORM_THREADS = 'threads'
32 35
33 36
34 class ThreadView(BaseBoardView, PostMixin, FormMixin):
37 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
35 38
36 39 def get(self, request, post_id, form: PostForm=None):
37 40 try:
38 41 opening_post = Post.objects.get(id=post_id)
39 42 except ObjectDoesNotExist:
40 43 raise Http404
41 44
45 # If the tag is favorite, update the counter
46 settings_manager = get_settings_manager(request)
47 favorite = settings_manager.thread_is_fav(opening_post)
48 if favorite:
49 settings_manager.add_or_read_fav_thread(opening_post)
50
42 51 # If this is not OP, don't show it as it is
43 52 if not opening_post.is_opening():
44 53 return redirect(opening_post.get_thread().get_opening_post()
45 .get_absolute_url())
54 .get_absolute_url())
46 55
47 56 if not form:
48 57 form = PostForm(error_class=PlainErrorList)
49 58
50 59 thread_to_show = opening_post.get_thread()
51 60
52 61 params = dict()
53 62
54 63 params[CONTEXT_FORM] = form
55 64 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
56 65 params[CONTEXT_THREAD] = thread_to_show
57 66 params[CONTEXT_MODE] = self.get_mode()
58 67 params[CONTEXT_OP] = opening_post
68 params[CONTEXT_FAVORITE] = favorite
59 69
60 70 if settings.get_bool('External', 'WebsocketsEnabled'):
61 71 token_time = format(timezone.now(), u'U')
62 72
63 73 params[CONTEXT_WS_TIME] = token_time
64 74 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
65 75 timestamp=token_time)
66 76 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
67 77 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
68 78 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
69 79
70 80 params.update(self.get_data(thread_to_show))
71 81
72 82 return render(request, self.get_template(), params)
73 83
74 84 def post(self, request, post_id):
75 85 opening_post = get_object_or_404(Post, id=post_id)
76 86
77 87 # If this is not OP, don't show it as it is
78 88 if not opening_post.is_opening():
79 89 raise Http404
80 90
91 if 'method' in request.POST:
92 self.dispatch_method(request, opening_post)
93
94 return redirect('thread', post_id) # FIXME Different for different modes
95
81 96 if not opening_post.get_thread().archived:
82 97 form = PostForm(request.POST, request.FILES,
83 98 error_class=PlainErrorList)
84 99 form.session = request.session
85 100
86 101 if form.is_valid():
87 102 return self.new_post(request, form, opening_post)
88 103 if form.need_to_ban:
89 104 # Ban user because he is suspected to be a bot
90 105 self._ban_current_user(request)
91 106
92 107 return self.get(request, post_id, form)
93 108
94 109 def new_post(self, request, form: PostForm, opening_post: Post=None,
95 110 html_response=True):
96 111 """
97 112 Adds a new post (in thread or as a reply).
98 113 """
99 114
100 115 ip = utils.get_client_ip(request)
101 116
102 117 data = form.cleaned_data
103 118
104 119 title = form.get_title()
105 120 text = data[FORM_TEXT]
106 121 file = form.get_file()
107 122 threads = data[FORM_THREADS]
108 123
109 124 text = self._remove_invalid_links(text)
110 125
111 126 post_thread = opening_post.get_thread()
112 127
113 128 post = Post.objects.create_post(title=title, text=text, file=file,
114 129 thread=post_thread, ip=ip,
115 130 opening_posts=threads,
116 131 tripcode=form.get_tripcode())
117 132 post.notify_clients()
118 133
119 134 if html_response:
120 135 if opening_post:
121 136 return redirect(post.get_absolute_url())
122 137 else:
123 138 return post
124 139
125 140 def get_data(self, thread) -> dict:
126 141 """
127 142 Returns context params for the view.
128 143 """
129 144
130 145 return dict()
131 146
132 147 def get_template(self) -> str:
133 148 """
134 149 Gets template to show the thread mode on.
135 150 """
136 151
137 152 pass
138 153
139 154 def get_mode(self) -> str:
140 155 pass
156
157 def subscribe(self, request, opening_post):
158 settings_manager = get_settings_manager(request)
159 settings_manager.add_or_read_fav_thread(opening_post)
160
161 def unsubscribe(self, request, opening_post):
162 settings_manager = get_settings_manager(request)
163 settings_manager.del_fav_thread(opening_post)
@@ -1,10 +1,11 b''
1 1 httplib2
2 2 simplejson
3 pytube
3 4 requests
4 5 adjacent
5 6 django-haystack
6 7 pillow
7 8 django>=1.8
8 9 bbcode
9 10 django-debug-toolbar
10 11 pytz
General Comments 0
You need to be logged in to leave comments. Login now