##// END OF EJS Templates
Merge remote changes
bodqhrohro -
r1578:b47850ce merge default
parent child Browse files
Show More
@@ -0,0 +1,20 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-05-11 09:23
3 from __future__ import unicode_literals
4
5 from django.db import migrations, models
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0044_globalid_content'),
12 ]
13
14 operations = [
15 migrations.AddField(
16 model_name='post',
17 name='version',
18 field=models.IntegerField(default=1),
19 ),
20 ]
@@ -1,6 +1,7 b''
1 from django.contrib import admin
1 from django.contrib import admin
2 from django.utils.translation import ugettext_lazy as _
2 from django.utils.translation import ugettext_lazy as _
3 from django.core.urlresolvers import reverse
3 from django.core.urlresolvers import reverse
4 from django.db.models import F
4 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair, GlobalId
5 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair, GlobalId
5
6
6
7
@@ -12,7 +13,8 b' class PostAdmin(admin.ModelAdmin):'
12 search_fields = ('id', 'title', 'text', 'poster_ip')
13 search_fields = ('id', 'title', 'text', 'poster_ip')
13 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
14 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
14 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
15 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
15 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id')
16 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
17 'version')
16
18
17 def ban_poster(self, request, queryset):
19 def ban_poster(self, request, queryset):
18 bans = 0
20 bans = 0
@@ -54,6 +56,11 b' class PostAdmin(admin.ModelAdmin):'
54 args=[global_id.id]), str(global_id))
56 args=[global_id.id]), str(global_id))
55 linked_global_id.allow_tags = True
57 linked_global_id.allow_tags = True
56
58
59 def save_model(self, request, obj, form, change):
60 obj.increment_version()
61 obj.save()
62 obj.clear_cache()
63
57 actions = ['ban_poster', 'ban_with_hiding']
64 actions = ['ban_poster', 'ban_with_hiding']
58
65
59
66
@@ -96,6 +103,14 b' class ThreadAdmin(admin.ModelAdmin):'
96 def save_related(self, request, form, formsets, change):
103 def save_related(self, request, form, formsets, change):
97 super().save_related(request, form, formsets, change)
104 super().save_related(request, form, formsets, change)
98 form.instance.refresh_tags()
105 form.instance.refresh_tags()
106
107 def save_model(self, request, obj, form, change):
108 op = obj.get_opening_post()
109 op.increment_version()
110 op.save(update_fields=['version'])
111 obj.save()
112 op.clear_cache()
113
99 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
114 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
100 'display_tags')
115 'display_tags')
101 list_filter = ('bump_time', 'status')
116 list_filter = ('bump_time', 'status')
@@ -12,9 +12,11 b' class Command(BaseCommand):'
12 @transaction.atomic
12 @transaction.atomic
13 def handle(self, *args, **options):
13 def handle(self, *args, **options):
14 count = 0
14 count = 0
15 for global_id in GlobalId.objects.all():
15 for global_id in GlobalId.objects.exclude(content__isnull=True).exclude(
16 content=''):
16 if global_id.is_local() and global_id.content is not None:
17 if global_id.is_local() and global_id.content is not None:
17 global_id.content = None
18 global_id.content = None
18 global_id.save()
19 global_id.save()
20 global_id.signature_set.all().delete()
19 count += 1
21 count += 1
20 print('Invalidated {} caches.'.format(count))
22 print('Invalidated {} caches.'.format(count))
@@ -5,7 +5,7 b' import httplib2'
5 from django.core.management import BaseCommand
5 from django.core.management import BaseCommand
6
6
7 from boards.models import GlobalId
7 from boards.models import GlobalId
8 from boards.models.post.sync import SyncManager
8 from boards.models.post.sync import SyncManager, TAG_ID, TAG_VERSION
9
9
10 __author__ = 'neko259'
10 __author__ = 'neko259'
11
11
@@ -27,7 +27,7 b' class Command(BaseCommand):'
27 def handle(self, *args, **options):
27 def handle(self, *args, **options):
28 url = options.get('url')
28 url = options.get('url')
29
29
30 pull_url = url + 'api/sync/pull/'
30 list_url = url + 'api/sync/list/'
31 get_url = url + 'api/sync/get/'
31 get_url = url + 'api/sync/get/'
32 file_url = url[:-1]
32 file_url = url[:-1]
33
33
@@ -52,8 +52,8 b' class Command(BaseCommand):'
52 raise Exception('Invalid global ID')
52 raise Exception('Invalid global ID')
53 else:
53 else:
54 h = httplib2.Http()
54 h = httplib2.Http()
55 xml = GlobalId.objects.generate_request_pull()
55 xml = GlobalId.objects.generate_request_list()
56 response, content = h.request(pull_url, method="POST", body=xml)
56 response, content = h.request(list_url, method="POST", body=xml)
57
57
58 print(content.decode() + '\n')
58 print(content.decode() + '\n')
59
59
@@ -64,11 +64,16 b' class Command(BaseCommand):'
64
64
65 models = root.findall('models')[0]
65 models = root.findall('models')[0]
66 for model in models:
66 for model in models:
67 global_id, exists = GlobalId.from_xml_element(model)
67 tag_id = model.find(TAG_ID)
68 if not exists:
68 global_id, exists = GlobalId.from_xml_element(tag_id)
69 print(global_id)
69 tag_version = model.find(TAG_VERSION)
70 if tag_version is not None:
71 version = int(tag_version.text) or 1
72 else:
73 version = 1
74 if not exists or global_id.post.version < version:
70 ids_to_sync.append(global_id)
75 ids_to_sync.append(global_id)
71 print()
76 print('Starting sync...')
72
77
73 if len(ids_to_sync) > 0:
78 if len(ids_to_sync) > 0:
74 limit = options.get('split_query', len(ids_to_sync))
79 limit = options.get('split_query', len(ids_to_sync))
@@ -11,7 +11,7 b' from boards.utils import datetime_to_epo'
11 from django.core.exceptions import ObjectDoesNotExist
11 from django.core.exceptions import ObjectDoesNotExist
12 from django.core.urlresolvers import reverse
12 from django.core.urlresolvers import reverse
13 from django.db import models
13 from django.db import models
14 from django.db.models import TextField, QuerySet
14 from django.db.models import TextField, QuerySet, F
15 from django.template.defaultfilters import truncatewords, striptags
15 from django.template.defaultfilters import truncatewords, striptags
16 from django.template.loader import render_to_string
16 from django.template.loader import render_to_string
17
17
@@ -104,6 +104,7 b' class Post(models.Model, Viewable):'
104 tripcode = models.CharField(max_length=50, blank=True, default='')
104 tripcode = models.CharField(max_length=50, blank=True, default='')
105 opening = models.BooleanField(db_index=True)
105 opening = models.BooleanField(db_index=True)
106 hidden = models.BooleanField(default=False)
106 hidden = models.BooleanField(default=False)
107 version = models.IntegerField(default=1)
107
108
108 def __str__(self):
109 def __str__(self):
109 return 'P#{}/{}'.format(self.id, self.get_title())
110 return 'P#{}/{}'.format(self.id, self.get_title())
@@ -337,8 +338,11 b' class Post(models.Model, Viewable):'
337
338
338 replacements = dict()
339 replacements = dict()
339 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
340 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
340 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
341 try:
341 replacements[post_id] = absolute_post_id
342 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
343 replacements[post_id] = absolute_post_id
344 except Post.DoesNotExist:
345 pass
342
346
343 text = self.get_raw_text() or ''
347 text = self.get_raw_text() or ''
344 for key in replacements:
348 for key in replacements:
@@ -379,3 +383,14 b' class Post(models.Model, Viewable):'
379
383
380 def set_hidden(self, hidden):
384 def set_hidden(self, hidden):
381 self.hidden = hidden
385 self.hidden = hidden
386
387 def increment_version(self):
388 self.version = F('version') + 1
389
390 def clear_cache(self):
391 global_id = self.global_id
392 if global_id is not None and global_id.is_local()\
393 and global_id.content is not None:
394 global_id.content = None
395 global_id.save()
396 global_id.signature_set.all().delete()
@@ -32,6 +32,7 b" TAG_TAG = 'tag'"
32 TAG_ATTACHMENT_REFS = 'attachment-refs'
32 TAG_ATTACHMENT_REFS = 'attachment-refs'
33 TAG_ATTACHMENT_REF = 'attachment-ref'
33 TAG_ATTACHMENT_REF = 'attachment-ref'
34 TAG_TRIPCODE = 'tripcode'
34 TAG_TRIPCODE = 'tripcode'
35 TAG_VERSION = 'version'
35
36
36 TYPE_GET = 'get'
37 TYPE_GET = 'get'
37
38
@@ -126,6 +127,8 b' class SyncManager:'
126 SyncManager._attachment_to_xml(
127 SyncManager._attachment_to_xml(
127 attachments_tag, attachment_refs, file.file.file,
128 attachments_tag, attachment_refs, file.file.file,
128 file.hash, file.file.url)
129 file.hash, file.file.url)
130 version_tag = et.SubElement(content_tag, TAG_VERSION)
131 version_tag.text = str(post.version)
129
132
130 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
133 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
131 global_id.save()
134 global_id.save()
@@ -227,11 +230,12 b' class SyncManager:'
227 title=title, text=text, pub_time=pub_time,
230 title=title, text=text, pub_time=pub_time,
228 opening_post=opening_post, tags=tags,
231 opening_post=opening_post, tags=tags,
229 global_id=global_id, files=files, tripcode=tripcode)
232 global_id=global_id, files=files, tripcode=tripcode)
233 print('Parsed post {}'.format(global_id))
230 else:
234 else:
231 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
235 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
232
236
233 @staticmethod
237 @staticmethod
234 def generate_response_pull():
238 def generate_response_list():
235 response = et.Element(TAG_RESPONSE)
239 response = et.Element(TAG_RESPONSE)
236
240
237 status = et.SubElement(response, TAG_STATUS)
241 status = et.SubElement(response, TAG_STATUS)
@@ -240,8 +244,11 b' class SyncManager:'
240 models = et.SubElement(response, TAG_MODELS)
244 models = et.SubElement(response, TAG_MODELS)
241
245
242 for post in Post.objects.prefetch_related('global_id').all():
246 for post in Post.objects.prefetch_related('global_id').all():
243 tag_id = et.SubElement(models, TAG_ID)
247 tag_model = et.SubElement(models, TAG_MODEL)
248 tag_id = et.SubElement(tag_model, TAG_ID)
244 post.global_id.to_xml_element(tag_id)
249 post.global_id.to_xml_element(tag_id)
250 tag_version = et.SubElement(tag_model, TAG_VERSION)
251 tag_version.text = str(post.version)
245
252
246 return et.tostring(response, ENCODING_UNICODE)
253 return et.tostring(response, ENCODING_UNICODE)
247
254
@@ -8,7 +8,7 b" TAG_REQUEST = 'request'"
8 TAG_ID = 'id'
8 TAG_ID = 'id'
9
9
10 TYPE_GET = 'get'
10 TYPE_GET = 'get'
11 TYPE_PULL = 'pull'
11 TYPE_LIST = 'list'
12
12
13 ATTR_VERSION = 'version'
13 ATTR_VERSION = 'version'
14 ATTR_TYPE = 'type'
14 ATTR_TYPE = 'type'
@@ -39,13 +39,13 b' class GlobalIdManager(models.Manager):'
39
39
40 return et.tostring(request, 'unicode')
40 return et.tostring(request, 'unicode')
41
41
42 def generate_request_pull(self):
42 def generate_request_list(self):
43 """
43 """
44 Form a pull request from a list of ModelId objects.
44 Form a pull request from a list of ModelId objects.
45 """
45 """
46
46
47 request = et.Element(TAG_REQUEST)
47 request = et.Element(TAG_REQUEST)
48 request.set(ATTR_TYPE, TYPE_PULL)
48 request.set(ATTR_TYPE, TYPE_LIST)
49 request.set(ATTR_VERSION, '1.0')
49 request.set(ATTR_VERSION, '1.0')
50
50
51 model = et.SubElement(request, TAG_MODEL)
51 model = et.SubElement(request, TAG_MODEL)
@@ -96,17 +96,24 b' function addQuickReply(postId) {'
96 }
96 }
97
97
98 function addQuickQuote() {
98 function addQuickQuote() {
99 var textAreaJq = $('textarea');
100
101 var quoteButton = $("#quote-button");
102 var postId = quoteButton.attr('data-post-id');
103 if (postId != null && getForm().prev().attr('id') != postId) {
104 addQuickReply(postId);
105 }
106
99 var textToAdd = '';
107 var textToAdd = '';
100 var textAreaJq = $('textarea');
101 var selection = window.getSelection().toString();
108 var selection = window.getSelection().toString();
102 if (selection.length == 0) {
109 if (selection.length == 0) {
103 selection = $("#quote-button").attr('data-text');
110 selection = quoteButton.attr('data-text');
104 }
111 }
105 if (selection.length > 0) {
112 if (selection.length > 0) {
106 textToAdd += '[quote]' + selection + '[/quote]\n';
113 textToAdd += '[quote]' + selection + '[/quote]\n';
107 }
114 }
108
115
109 textAreaJq.val(textAreaJq.val()+ textToAdd);
116 textAreaJq.val(textAreaJq.val() + textToAdd);
110
117
111 textAreaJq.focus();
118 textAreaJq.focus();
112
119
@@ -128,6 +135,15 b' function showQuoteButton() {'
128
135
129 var text = window.getSelection().toString();
136 var text = window.getSelection().toString();
130 quoteButton.attr('data-text', text);
137 quoteButton.attr('data-text', text);
138
139 var rect = window.getSelection().getRangeAt(0).getBoundingClientRect();
140 var element = $(document.elementFromPoint(rect.x, rect.y));
141 var postParent = element.parents('.post');
142 if (postParent.length > 0) {
143 quoteButton.attr('data-post-id', postParent.attr('id'));
144 } else {
145 quoteButton.attr('data-post-id', null);
146 }
131 } else {
147 } else {
132 quoteButton.hide();
148 quoteButton.hide();
133 }
149 }
@@ -71,6 +71,7 b' class KeyTest(TestCase):'
71 '<text>[post]%s[/post]</text>'
71 '<text>[post]%s[/post]</text>'
72 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
72 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
73 '<pub-time>%s</pub-time>'
73 '<pub-time>%s</pub-time>'
74 '<version>%s</version>'
74 '</content>' % (
75 '</content>' % (
75 key.public_key,
76 key.public_key,
76 reply_post.id,
77 reply_post.id,
@@ -80,6 +81,7 b' class KeyTest(TestCase):'
80 post.id,
81 post.id,
81 key.key_type,
82 key.key_type,
82 str(reply_post.get_pub_time_str()),
83 str(reply_post.get_pub_time_str()),
84 post.version,
83 ) in response,
85 ) in response,
84 'Wrong XML generated for the GET response.')
86 'Wrong XML generated for the GET response.')
85
87
@@ -43,6 +43,7 b' class SyncTest(TestCase):'
43 '<text>%s</text>'
43 '<text>%s</text>'
44 '<tags><tag>%s</tag></tags>'
44 '<tags><tag>%s</tag></tags>'
45 '<pub-time>%s</pub-time>'
45 '<pub-time>%s</pub-time>'
46 '<version>%s</version>'
46 '</content>' % (
47 '</content>' % (
47 post.global_id.key,
48 post.global_id.key,
48 post.global_id.local_id,
49 post.global_id.local_id,
@@ -51,6 +52,7 b' class SyncTest(TestCase):'
51 post.get_sync_text(),
52 post.get_sync_text(),
52 post.get_thread().get_tags().first().name,
53 post.get_thread().get_tags().first().name,
53 post.get_pub_time_str(),
54 post.get_pub_time_str(),
55 post.version,
54 ) in response,
56 ) in response,
55 'Wrong response generated for the GET request.')
57 'Wrong response generated for the GET request.')
56
58
@@ -90,6 +92,7 b' class SyncTest(TestCase):'
90 '<text>%s</text>'
92 '<text>%s</text>'
91 '<tags><tag>%s</tag></tags>'
93 '<tags><tag>%s</tag></tags>'
92 '<pub-time>%s</pub-time>'
94 '<pub-time>%s</pub-time>'
95 '<version>%s</version>'
93 '</content>' % (
96 '</content>' % (
94 post.global_id.key,
97 post.global_id.key,
95 post.global_id.local_id,
98 post.global_id.local_id,
@@ -98,5 +101,6 b' class SyncTest(TestCase):'
98 post.get_sync_text(),
101 post.get_sync_text(),
99 post.get_thread().get_tags().first().name,
102 post.get_thread().get_tags().first().name,
100 post.get_pub_time_str(),
103 post.get_pub_time_str(),
104 post.version,
101 ) in response,
105 ) in response,
102 'Wrong response generated for the GET request.')
106 'Wrong response generated for the GET request.')
@@ -1,5 +1,4 b''
1 from django.conf.urls import url
1 from django.conf.urls import url
2 #from django.views.i18n import javascript_catalog
3
2
4 import neboard
3 import neboard
5
4
@@ -9,10 +8,9 b' from boards.views import api, tag_thread'
9 settings, all_tags, feed
8 settings, all_tags, feed
10 from boards.views.authors import AuthorsView
9 from boards.views.authors import AuthorsView
11 from boards.views.notifications import NotificationView
10 from boards.views.notifications import NotificationView
12 from boards.views.search import BoardSearchView
13 from boards.views.static import StaticPageView
11 from boards.views.static import StaticPageView
14 from boards.views.preview import PostPreviewView
12 from boards.views.preview import PostPreviewView
15 from boards.views.sync import get_post_sync_data, response_get, response_pull
13 from boards.views.sync import get_post_sync_data, response_get, response_list
16 from boards.views.random import RandomImageView
14 from boards.views.random import RandomImageView
17 from boards.views.tag_gallery import TagGalleryView
15 from boards.views.tag_gallery import TagGalleryView
18 from boards.views.translation import cached_javascript_catalog
16 from boards.views.translation import cached_javascript_catalog
@@ -78,9 +76,8 b' urlpatterns = ['
78 url(r'^api/new_posts/$', api.api_get_new_posts, name='new_posts'),
76 url(r'^api/new_posts/$', api.api_get_new_posts, name='new_posts'),
79
77
80 # Sync protocol API
78 # Sync protocol API
81 url(r'^api/sync/pull/$', response_pull, name='api_sync_pull'),
79 url(r'^api/sync/list/$', response_list, name='api_sync_list'),
82 url(r'^api/sync/get/$', response_get, name='api_sync_pull'),
80 url(r'^api/sync/get/$', response_get, name='api_sync_get'),
83 # TODO 'get' request
84
81
85 # Notifications
82 # Notifications
86 url(r'^notifications/(?P<username>\w+)/$', NotificationView.as_view(), name='notifications'),
83 url(r'^notifications/(?P<username>\w+)/$', NotificationView.as_view(), name='notifications'),
@@ -1,18 +1,17 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2 import xml.dom.minidom
3
2
4 from django.http import HttpResponse, Http404
3 from django.http import HttpResponse, Http404
5 from boards.models import GlobalId, Post
4 from boards.models import GlobalId, Post
6 from boards.models.post.sync import SyncManager
5 from boards.models.post.sync import SyncManager
7
6
8
7
9 def response_pull(request):
8 def response_list(request):
10 request_xml = request.body
9 request_xml = request.body
11
10
12 if request_xml is None:
11 if request_xml is None or len(request_xml) == 0:
13 return HttpResponse(content='Use the API')
12 return HttpResponse(content='Use the API')
14
13
15 response_xml = SyncManager.generate_response_pull()
14 response_xml = SyncManager.generate_response_list()
16
15
17 return HttpResponse(content=response_xml)
16 return HttpResponse(content=response_xml)
18
17
@@ -25,7 +24,7 b' def response_get(request):'
25
24
26 request_xml = request.body
25 request_xml = request.body
27
26
28 if request_xml is None:
27 if request_xml is None or len(request_xml) == 0:
29 return HttpResponse(content='Use the API')
28 return HttpResponse(content='Use the API')
30
29
31 posts = []
30 posts = []
@@ -50,13 +49,7 b' def get_post_sync_data(request, post_id)'
50
49
51 xml_str = SyncManager.generate_response_get([post])
50 xml_str = SyncManager.generate_response_get([post])
52
51
53 xml_repr = xml.dom.minidom.parseString(xml_str)
54 xml_repr = xml_repr.toprettyxml()
55
56 content = '=Global ID=\n%s\n\n=XML=\n%s' \
57 % (post.global_id, xml_repr)
58
59 return HttpResponse(
52 return HttpResponse(
60 content_type='text/plain; charset=utf-8',
53 content_type='text/xml; charset=utf-8',
61 content=content,
54 content=xml_str,
62 ) No newline at end of file
55 )
@@ -43,7 +43,7 b' Parameters:'
43 * ``last_update``: last update timestamp
43 * ``last_update``: last update timestamp
44 * ``last_post``: last added post id
44 * ``last_post``: last added post id
45
45
46 Get the diff of the thread in the `O``
46 Get the diff of the thread in the ``O``
47 format. 2 formats are available: ``html`` (used in AJAX thread update) and
47 format. 2 formats are available: ``html`` (used in AJAX thread update) and
48 ``json``. The default format is ``html``. Return list format:
48 ``json``. The default format is ``html``. Return list format:
49
49
@@ -61,15 +61,15 b' responsible for validating it.'
61
61
62 The server is required to return the status of request. See 3.2 for details.
62 The server is required to return the status of request. See 3.2 for details.
63
63
64 ### 3.1.1 pull ###
64 ### 3.1.1 list ###
65
65
66 "pull" request gets the desired model id list by the given filter (e.g. thread, tags,
66 "list" request gets the desired model id list by the given filter (e.g. thread, tags,
67 author)
67 author)
68
68
69 Sample request is as follows:
69 Sample request is as follows:
70
70
71 <?xml version="1.1" encoding="UTF-8" ?>
71 <?xml version="1.1" encoding="UTF-8" ?>
72 <request version="1.0" type="pull">
72 <request version="1.0" type="list">
73 <model version="1.0" name="post">
73 <model version="1.0" name="post">
74 <timestamp_from>0</timestamp_from>
74 <timestamp_from>0</timestamp_from>
75 <timestamp_to>0</timestamp_to>
75 <timestamp_to>0</timestamp_to>
@@ -95,10 +95,17 b' Sample response:'
95 <response>
95 <response>
96 <status>success</status>
96 <status>success</status>
97 <models>
97 <models>
98 <id key="id1" type="ecdsa" local-id="1" />
98 <model>
99 <id key="id1" type="ecdsa" local-id="2" />
99 <id key="id1" type="ecdsa" local-id="1">
100 <id key="id2" type="ecdsa" local-id="1" />
100 <version>1</version>
101 <id key="id2" type="ecdsa" local-id="5" />
101 </model>
102 <model>
103 <id key="id1" type="ecdsa" local-id="2" />
104 </model>
105 <model>
106 <id key="id2" type="ecdsa" local-id="1" />
107 <some-valuable-attr>value</some-valuable-attr>
108 </model>
102 </models>
109 </models>
103 </response>
110 </response>
104
111
@@ -13,6 +13,7 b''
13 * title -- text field.
13 * title -- text field.
14 * text -- text field.
14 * text -- text field.
15 * pub-time -- timestamp (TBD: Define format).
15 * pub-time -- timestamp (TBD: Define format).
16 * version -- when post content changes, version should be incremented.
16
17
17 # 2.2 Optional fields #
18 # 2.2 Optional fields #
18
19
General Comments 0
You need to be logged in to leave comments. Login now