Merge
311
daemon.py
|
|
@ -4050,6 +4050,7 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
postBytesStr = postBytes.decode('utf-8')
|
postBytesStr = postBytes.decode('utf-8')
|
||||||
redirectPath = ''
|
redirectPath = ''
|
||||||
checkNameAndBio = False
|
checkNameAndBio = False
|
||||||
|
onFinalWelcomeScreen = False
|
||||||
if 'name="previewAvatar"' in postBytesStr:
|
if 'name="previewAvatar"' in postBytesStr:
|
||||||
redirectPath = '/welcome_profile'
|
redirectPath = '/welcome_profile'
|
||||||
elif 'name="initialWelcomeScreen"' in postBytesStr:
|
elif 'name="initialWelcomeScreen"' in postBytesStr:
|
||||||
|
|
@ -4061,6 +4062,7 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
redirectPath = '/' + self.server.defaultTimeline
|
redirectPath = '/' + self.server.defaultTimeline
|
||||||
welcomeScreenIsComplete(self.server.baseDir, nickname,
|
welcomeScreenIsComplete(self.server.baseDir, nickname,
|
||||||
self.server.domain)
|
self.server.domain)
|
||||||
|
onFinalWelcomeScreen = True
|
||||||
|
|
||||||
# extract all of the text fields into a dict
|
# extract all of the text fields into a dict
|
||||||
fields = \
|
fields = \
|
||||||
|
|
@ -4717,15 +4719,20 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
nickname, domain)
|
nickname, domain)
|
||||||
|
|
||||||
# approve followers
|
# approve followers
|
||||||
approveFollowers = False
|
if onFinalWelcomeScreen:
|
||||||
if fields.get('approveFollowers'):
|
# Default setting created via the welcome screen
|
||||||
if fields['approveFollowers'] == 'on':
|
actorJson['manuallyApprovesFollowers'] = True
|
||||||
approveFollowers = True
|
|
||||||
if approveFollowers != \
|
|
||||||
actorJson['manuallyApprovesFollowers']:
|
|
||||||
actorJson['manuallyApprovesFollowers'] = \
|
|
||||||
approveFollowers
|
|
||||||
actorChanged = True
|
actorChanged = True
|
||||||
|
else:
|
||||||
|
approveFollowers = False
|
||||||
|
if fields.get('approveFollowers'):
|
||||||
|
if fields['approveFollowers'] == 'on':
|
||||||
|
approveFollowers = True
|
||||||
|
if approveFollowers != \
|
||||||
|
actorJson['manuallyApprovesFollowers']:
|
||||||
|
actorJson['manuallyApprovesFollowers'] = \
|
||||||
|
approveFollowers
|
||||||
|
actorChanged = True
|
||||||
|
|
||||||
# remove a custom font
|
# remove a custom font
|
||||||
if fields.get('removeCustomFont'):
|
if fields.get('removeCustomFont'):
|
||||||
|
|
@ -4773,15 +4780,22 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
baseDir + '/accounts/' + \
|
baseDir + '/accounts/' + \
|
||||||
nickname + '@' + domain + \
|
nickname + '@' + domain + \
|
||||||
'/.followDMs'
|
'/.followDMs'
|
||||||
followDMsActive = False
|
if onFinalWelcomeScreen:
|
||||||
if fields.get('followDMs'):
|
# initial default setting created via
|
||||||
if fields['followDMs'] == 'on':
|
# the welcome screen
|
||||||
followDMsActive = True
|
with open(followDMsFilename, 'w+') as fFile:
|
||||||
with open(followDMsFilename, 'w+') as fFile:
|
fFile.write('\n')
|
||||||
fFile.write('\n')
|
actorChanged = True
|
||||||
if not followDMsActive:
|
else:
|
||||||
if os.path.isfile(followDMsFilename):
|
followDMsActive = False
|
||||||
os.remove(followDMsFilename)
|
if fields.get('followDMs'):
|
||||||
|
if fields['followDMs'] == 'on':
|
||||||
|
followDMsActive = True
|
||||||
|
with open(followDMsFilename, 'w+') as fFile:
|
||||||
|
fFile.write('\n')
|
||||||
|
if not followDMsActive:
|
||||||
|
if os.path.isfile(followDMsFilename):
|
||||||
|
os.remove(followDMsFilename)
|
||||||
|
|
||||||
# remove Twitter retweets
|
# remove Twitter retweets
|
||||||
removeTwitterFilename = \
|
removeTwitterFilename = \
|
||||||
|
|
@ -4822,16 +4836,22 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
os.remove(hideLikeButtonFile)
|
os.remove(hideLikeButtonFile)
|
||||||
|
|
||||||
# notify about new Likes
|
# notify about new Likes
|
||||||
notifyLikesActive = False
|
if onFinalWelcomeScreen:
|
||||||
if fields.get('notifyLikes'):
|
# default setting from welcome screen
|
||||||
if fields['notifyLikes'] == 'on' and \
|
with open(notifyLikesFilename, 'w+') as rFile:
|
||||||
not hideLikeButtonActive:
|
rFile.write('\n')
|
||||||
notifyLikesActive = True
|
actorChanged = True
|
||||||
with open(notifyLikesFilename, 'w+') as rFile:
|
else:
|
||||||
rFile.write('\n')
|
notifyLikesActive = False
|
||||||
if not notifyLikesActive:
|
if fields.get('notifyLikes'):
|
||||||
if os.path.isfile(notifyLikesFilename):
|
if fields['notifyLikes'] == 'on' and \
|
||||||
os.remove(notifyLikesFilename)
|
not hideLikeButtonActive:
|
||||||
|
notifyLikesActive = True
|
||||||
|
with open(notifyLikesFilename, 'w+') as rFile:
|
||||||
|
rFile.write('\n')
|
||||||
|
if not notifyLikesActive:
|
||||||
|
if os.path.isfile(notifyLikesFilename):
|
||||||
|
os.remove(notifyLikesFilename)
|
||||||
|
|
||||||
# this account is a bot
|
# this account is a bot
|
||||||
if fields.get('isBot'):
|
if fields.get('isBot'):
|
||||||
|
|
@ -5751,6 +5771,52 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
return
|
return
|
||||||
self._404()
|
self._404()
|
||||||
|
|
||||||
|
def _showHelpScreenImage(self, callingDomain: str, path: str,
|
||||||
|
baseDir: str,
|
||||||
|
GETstartTime, GETtimings: {}) -> None:
|
||||||
|
"""Shows a help screen image
|
||||||
|
"""
|
||||||
|
if not path.endswith('.jpg') and \
|
||||||
|
not path.endswith('.png') and \
|
||||||
|
not path.endswith('.webp') and \
|
||||||
|
not path.endswith('.avif') and \
|
||||||
|
not path.endswith('.gif'):
|
||||||
|
return
|
||||||
|
mediaStr = path.split('/helpimages/')[1]
|
||||||
|
if '/' not in mediaStr:
|
||||||
|
if not self.server.themeName:
|
||||||
|
theme = 'default'
|
||||||
|
else:
|
||||||
|
theme = self.server.themeName
|
||||||
|
iconFilename = mediaStr
|
||||||
|
else:
|
||||||
|
theme = mediaStr.split('/')[0]
|
||||||
|
iconFilename = mediaStr.split('/')[1]
|
||||||
|
mediaFilename = \
|
||||||
|
baseDir + '/theme/' + theme + '/helpimages/' + iconFilename
|
||||||
|
# if there is no theme-specific help image then use the default one
|
||||||
|
if not os.path.isfile(mediaFilename):
|
||||||
|
mediaFilename = \
|
||||||
|
baseDir + '/theme/default/helpimages/' + iconFilename
|
||||||
|
if self._etag_exists(mediaFilename):
|
||||||
|
# The file has not changed
|
||||||
|
self._304()
|
||||||
|
return
|
||||||
|
if os.path.isfile(mediaFilename):
|
||||||
|
with open(mediaFilename, 'rb') as avFile:
|
||||||
|
mediaBinary = avFile.read()
|
||||||
|
mimeType = mediaFileMimeType(mediaFilename)
|
||||||
|
self._set_headers_etag(mediaFilename,
|
||||||
|
mimeType,
|
||||||
|
mediaBinary, None,
|
||||||
|
self.server.domainFull)
|
||||||
|
self._write(mediaBinary)
|
||||||
|
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
||||||
|
'show files done',
|
||||||
|
'help image shown')
|
||||||
|
return
|
||||||
|
self._404()
|
||||||
|
|
||||||
def _showCachedAvatar(self, callingDomain: str, path: str,
|
def _showCachedAvatar(self, callingDomain: str, path: str,
|
||||||
baseDir: str,
|
baseDir: str,
|
||||||
GETstartTime, GETtimings: {}) -> None:
|
GETstartTime, GETtimings: {}) -> None:
|
||||||
|
|
@ -9669,7 +9735,7 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
"""
|
"""
|
||||||
imageExtensions = getImageExtensions()
|
imageExtensions = getImageExtensions()
|
||||||
for ext in imageExtensions:
|
for ext in imageExtensions:
|
||||||
for bg in ('follow', 'options', 'login'):
|
for bg in ('follow', 'options', 'login', 'welcome'):
|
||||||
# follow screen background image
|
# follow screen background image
|
||||||
if path.endswith('/' + bg + '-background.' + ext):
|
if path.endswith('/' + bg + '-background.' + ext):
|
||||||
bgFilename = \
|
bgFilename = \
|
||||||
|
|
@ -9714,41 +9780,45 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
GETstartTime, GETtimings: {}) -> bool:
|
GETstartTime, GETtimings: {}) -> bool:
|
||||||
"""Show a shared item image
|
"""Show a shared item image
|
||||||
"""
|
"""
|
||||||
if self._pathIsImage(path):
|
if not self._pathIsImage(path):
|
||||||
mediaStr = path.split('/sharefiles/')[1]
|
self._404()
|
||||||
mediaFilename = \
|
return True
|
||||||
baseDir + '/sharefiles/' + mediaStr
|
|
||||||
if os.path.isfile(mediaFilename):
|
|
||||||
if self._etag_exists(mediaFilename):
|
|
||||||
# The file has not changed
|
|
||||||
self._304()
|
|
||||||
return True
|
|
||||||
|
|
||||||
mediaFileType = 'png'
|
mediaStr = path.split('/sharefiles/')[1]
|
||||||
if mediaFilename.endswith('.png'):
|
mediaFilename = \
|
||||||
mediaFileType = 'png'
|
baseDir + '/sharefiles/' + mediaStr
|
||||||
elif mediaFilename.endswith('.jpg'):
|
if not os.path.isfile(mediaFilename):
|
||||||
mediaFileType = 'jpeg'
|
self._404()
|
||||||
elif mediaFilename.endswith('.webp'):
|
return True
|
||||||
mediaFileType = 'webp'
|
|
||||||
elif mediaFilename.endswith('.avif'):
|
if self._etag_exists(mediaFilename):
|
||||||
mediaFileType = 'avif'
|
# The file has not changed
|
||||||
elif mediaFilename.endswith('.svg'):
|
self._304()
|
||||||
mediaFileType = 'svg+xml'
|
return True
|
||||||
else:
|
|
||||||
mediaFileType = 'gif'
|
mediaFileType = 'png'
|
||||||
with open(mediaFilename, 'rb') as avFile:
|
if mediaFilename.endswith('.png'):
|
||||||
mediaBinary = avFile.read()
|
mediaFileType = 'png'
|
||||||
self._set_headers_etag(mediaFilename,
|
elif mediaFilename.endswith('.jpg'):
|
||||||
'image/' + mediaFileType,
|
mediaFileType = 'jpeg'
|
||||||
mediaBinary, None,
|
elif mediaFilename.endswith('.webp'):
|
||||||
self.server.domainFull)
|
mediaFileType = 'webp'
|
||||||
self._write(mediaBinary)
|
elif mediaFilename.endswith('.avif'):
|
||||||
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
mediaFileType = 'avif'
|
||||||
'show media done',
|
elif mediaFilename.endswith('.svg'):
|
||||||
'share files shown')
|
mediaFileType = 'svg+xml'
|
||||||
return True
|
else:
|
||||||
self._404()
|
mediaFileType = 'gif'
|
||||||
|
with open(mediaFilename, 'rb') as avFile:
|
||||||
|
mediaBinary = avFile.read()
|
||||||
|
self._set_headers_etag(mediaFilename,
|
||||||
|
'image/' + mediaFileType,
|
||||||
|
mediaBinary, None,
|
||||||
|
self.server.domainFull)
|
||||||
|
self._write(mediaBinary)
|
||||||
|
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
||||||
|
'show media done',
|
||||||
|
'share files shown')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _showAvatarOrBanner(self, callingDomain: str, path: str,
|
def _showAvatarOrBanner(self, callingDomain: str, path: str,
|
||||||
|
|
@ -9756,59 +9826,62 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
GETstartTime, GETtimings: {}) -> bool:
|
GETstartTime, GETtimings: {}) -> bool:
|
||||||
"""Shows an avatar or banner or profile background image
|
"""Shows an avatar or banner or profile background image
|
||||||
"""
|
"""
|
||||||
if '/users/' in path:
|
if '/users/' not in path:
|
||||||
if self._pathIsImage(path):
|
return False
|
||||||
avatarStr = path.split('/users/')[1]
|
if not self._pathIsImage(path):
|
||||||
if '/' in avatarStr and '.temp.' not in path:
|
return False
|
||||||
avatarNickname = avatarStr.split('/')[0]
|
avatarStr = path.split('/users/')[1]
|
||||||
avatarFile = avatarStr.split('/')[1]
|
if not ('/' in avatarStr and '.temp.' not in path):
|
||||||
avatarFileExt = avatarFile.split('.')[-1]
|
return False
|
||||||
# remove any numbers, eg. avatar123.png becomes avatar.png
|
avatarNickname = avatarStr.split('/')[0]
|
||||||
if avatarFile.startswith('avatar'):
|
avatarFile = avatarStr.split('/')[1]
|
||||||
avatarFile = 'avatar.' + avatarFileExt
|
avatarFileExt = avatarFile.split('.')[-1]
|
||||||
elif avatarFile.startswith('banner'):
|
# remove any numbers, eg. avatar123.png becomes avatar.png
|
||||||
avatarFile = 'banner.' + avatarFileExt
|
if avatarFile.startswith('avatar'):
|
||||||
elif avatarFile.startswith('search_banner'):
|
avatarFile = 'avatar.' + avatarFileExt
|
||||||
avatarFile = 'search_banner.' + avatarFileExt
|
elif avatarFile.startswith('banner'):
|
||||||
elif avatarFile.startswith('image'):
|
avatarFile = 'banner.' + avatarFileExt
|
||||||
avatarFile = 'image.' + avatarFileExt
|
elif avatarFile.startswith('search_banner'):
|
||||||
elif avatarFile.startswith('left_col_image'):
|
avatarFile = 'search_banner.' + avatarFileExt
|
||||||
avatarFile = 'left_col_image.' + avatarFileExt
|
elif avatarFile.startswith('image'):
|
||||||
elif avatarFile.startswith('right_col_image'):
|
avatarFile = 'image.' + avatarFileExt
|
||||||
avatarFile = 'right_col_image.' + avatarFileExt
|
elif avatarFile.startswith('left_col_image'):
|
||||||
avatarFilename = \
|
avatarFile = 'left_col_image.' + avatarFileExt
|
||||||
baseDir + '/accounts/' + \
|
elif avatarFile.startswith('right_col_image'):
|
||||||
avatarNickname + '@' + domain + '/' + avatarFile
|
avatarFile = 'right_col_image.' + avatarFileExt
|
||||||
if os.path.isfile(avatarFilename):
|
avatarFilename = \
|
||||||
if self._etag_exists(avatarFilename):
|
baseDir + '/accounts/' + \
|
||||||
# The file has not changed
|
avatarNickname + '@' + domain + '/' + avatarFile
|
||||||
self._304()
|
if not os.path.isfile(avatarFilename):
|
||||||
return True
|
return False
|
||||||
mediaImageType = 'png'
|
if self._etag_exists(avatarFilename):
|
||||||
if avatarFile.endswith('.png'):
|
# The file has not changed
|
||||||
mediaImageType = 'png'
|
self._304()
|
||||||
elif avatarFile.endswith('.jpg'):
|
return True
|
||||||
mediaImageType = 'jpeg'
|
mediaImageType = 'png'
|
||||||
elif avatarFile.endswith('.gif'):
|
if avatarFile.endswith('.png'):
|
||||||
mediaImageType = 'gif'
|
mediaImageType = 'png'
|
||||||
elif avatarFile.endswith('.avif'):
|
elif avatarFile.endswith('.jpg'):
|
||||||
mediaImageType = 'avif'
|
mediaImageType = 'jpeg'
|
||||||
elif avatarFile.endswith('.svg'):
|
elif avatarFile.endswith('.gif'):
|
||||||
mediaImageType = 'svg+xml'
|
mediaImageType = 'gif'
|
||||||
else:
|
elif avatarFile.endswith('.avif'):
|
||||||
mediaImageType = 'webp'
|
mediaImageType = 'avif'
|
||||||
with open(avatarFilename, 'rb') as avFile:
|
elif avatarFile.endswith('.svg'):
|
||||||
mediaBinary = avFile.read()
|
mediaImageType = 'svg+xml'
|
||||||
self._set_headers_etag(avatarFilename,
|
else:
|
||||||
'image/' + mediaImageType,
|
mediaImageType = 'webp'
|
||||||
mediaBinary, None,
|
with open(avatarFilename, 'rb') as avFile:
|
||||||
self.server.domainFull)
|
mediaBinary = avFile.read()
|
||||||
self._write(mediaBinary)
|
self._set_headers_etag(avatarFilename,
|
||||||
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
'image/' + mediaImageType,
|
||||||
'icon shown done',
|
mediaBinary, None,
|
||||||
'avatar background shown')
|
self.server.domainFull)
|
||||||
return True
|
self._write(mediaBinary)
|
||||||
return False
|
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
||||||
|
'icon shown done',
|
||||||
|
'avatar background shown')
|
||||||
|
return True
|
||||||
|
|
||||||
def _confirmDeleteEvent(self, callingDomain: str, path: str,
|
def _confirmDeleteEvent(self, callingDomain: str, path: str,
|
||||||
baseDir: str, httpPrefix: str, cookie: str,
|
baseDir: str, httpPrefix: str, cookie: str,
|
||||||
|
|
@ -11042,6 +11115,14 @@ class PubServer(BaseHTTPRequestHandler):
|
||||||
GETstartTime, GETtimings)
|
GETstartTime, GETtimings)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# help screen images
|
||||||
|
# Note that this comes before the busy flag to avoid conflicts
|
||||||
|
if self.path.startswith('/helpimages/'):
|
||||||
|
self._showHelpScreenImage(callingDomain, self.path,
|
||||||
|
self.server.baseDir,
|
||||||
|
GETstartTime, GETtimings)
|
||||||
|
return
|
||||||
|
|
||||||
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
self._benchmarkGETtimings(GETstartTime, GETtimings,
|
||||||
'show files done',
|
'show files done',
|
||||||
'icon shown done')
|
'icon shown done')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
Direct messages will appear here, as a chronological timeline.
|
||||||
|
|
||||||
|
To avoid spam and improve security, by default you will only be able to receive direct messages *from people that you're following*. You can turn this off within your profile settings if you need to, by selecting the top **banner** and then the **edit** icon.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
Incoming posts will appear here, as a chronological timeline. If you send any posts they will also appear here.
|
||||||
|
|
||||||
|
### The top banner
|
||||||
|
At the top of the screen you can select the **banner** to switch to your profile, and edit it or log out.
|
||||||
|
|
||||||
|
### Timeline buttons and icons
|
||||||
|
The **buttons** below the top banner allow you to select different timelines. There are also **icons** on the right to **search**, view your **calendar** or create **new posts**.
|
||||||
|
|
||||||
|
The **show/hide** icon allows more timeline buttons to be shown, along with moderator controls.
|
||||||
|
|
||||||
|
### Left column
|
||||||
|
Here you can add **useful links**. This only appears on desktop displays or devices with larger screens. It is similar to a *blogroll*. You can only add or edit links if you have an **administrator** or **editor** role.
|
||||||
|
|
||||||
|
If you are on mobile then use the **links icon** at the top to read news.
|
||||||
|
|
||||||
|
### Right column
|
||||||
|
RSS feeds can be added in the right column, known as the *newswire*. This only appears on desktop displays or devices with larger screens. You can only add or edit feeds if you have an **administrator** or **editor** role, and incoming feed items can also be moderated.
|
||||||
|
|
||||||
|
If you are on mobile then use the **newswire icon** at the top to read news.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Your sent posts will appear here, as a cronological timeline.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Any bookmarked posts appear here.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Any incoming posts which contain **images**, **video** or **audio** files will appear here, together with their descriptions.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
### Shared items
|
||||||
|
These are typically physical objects or local services, exchanged or given away without use of money.
|
||||||
|
|
||||||
|
For example, you may want to share **equipment** between members of a sports team on the same instance, share any surplus **clothing**, share **gadgets** which you are no longer using, or share plants and gardening **tools** between people using the same growing space.
|
||||||
|
|
||||||
|
To avoid spam, shared items are not federated via ActivityPub and are local to members on the same instance.
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### مرحبًا بكم في INSTANCE
|
### مرحبًا بكم في INSTANCE
|
||||||
هذا خادم ActivityPub مصمم للاستضافة الذاتية السهلة لعدد قليل من الأشخاص على أنظمة منخفضة الطاقة ، مثل أجهزة الكمبيوتر ذات اللوحة الواحدة أو أجهزة الكمبيوتر المحمولة القديمة.
|
هذا خادم ActivityPub مصمم للاستضافة الذاتية السهلة لعدد قليل من الأشخاص على أنظمة منخفضة الطاقة ، مثل أجهزة الكمبيوتر ذات اللوحة الواحدة أو أجهزة الكمبيوتر المحمولة القديمة.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Benvingut a INSTANCE
|
### Benvingut a INSTANCE
|
||||||
Es tracta d’un servidor ActivityPub dissenyat per allotjar fàcilment algunes persones en sistemes de poca potència, com ara ordinadors de placa única o portàtils antics.
|
Es tracta d’un servidor ActivityPub dissenyat per allotjar fàcilment algunes persones en sistemes de poca potència, com ara ordinadors de placa única o portàtils antics.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Croeso i INSTANCE
|
### Croeso i INSTANCE
|
||||||
Gweinydd ActivityPub yw hwn sydd wedi'i gynllunio ar gyfer hunan-letya ychydig o bobl ar systemau pŵer isel yn hawdd, fel cyfrifiaduron bwrdd sengl neu hen gliniaduron.
|
Gweinydd ActivityPub yw hwn sydd wedi'i gynllunio ar gyfer hunan-letya ychydig o bobl ar systemau pŵer isel yn hawdd, fel cyfrifiaduron bwrdd sengl neu hen gliniaduron.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Willkommen bei INSTANCE
|
### Willkommen bei INSTANCE
|
||||||
Dies ist ein ActivityPub-Server, der für das einfache Selbsthosting einiger weniger Personen auf Systemen mit geringem Stromverbrauch wie Single-Board-Computern oder alten Laptops entwickelt wurde.
|
Dies ist ein ActivityPub-Server, der für das einfache Selbsthosting einiger weniger Personen auf Systemen mit geringem Stromverbrauch wie Single-Board-Computern oder alten Laptops entwickelt wurde.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Welcome to INSTANCE
|
### Welcome to INSTANCE
|
||||||
This is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops.
|
This is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Bienvenido a INSTANCE
|
### Bienvenido a INSTANCE
|
||||||
Este es un servidor ActivityPub diseñado para el autohospedaje sencillo de algunas personas en sistemas de bajo consumo de energía, como computadoras de placa única o laptops antiguas.
|
Este es un servidor ActivityPub diseñado para el autohospedaje sencillo de algunas personas en sistemas de bajo consumo de energía, como computadoras de placa única o laptops antiguas.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Bienvenue à INSTANCE
|
### Bienvenue à INSTANCE
|
||||||
Il s'agit d'un serveur ActivityPub conçu pour l'auto-hébergement facile de quelques personnes sur des systèmes à faible consommation d'énergie, tels que des ordinateurs monocarte ou d'anciens ordinateurs portables.
|
Il s'agit d'un serveur ActivityPub conçu pour l'auto-hébergement facile de quelques personnes sur des systèmes à faible consommation d'énergie, tels que des ordinateurs monocarte ou d'anciens ordinateurs portables.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Fáilte go INSTANCE
|
### Fáilte go INSTANCE
|
||||||
Is freastalaí ActivityPub é seo atá deartha chun féin-óstáil éasca a dhéanamh ar chúpla duine ar chórais ísealchumhachta, mar ríomhairí boird aonair nó sean ríomhairí glúine.
|
Is freastalaí ActivityPub é seo atá deartha chun féin-óstáil éasca a dhéanamh ar chúpla duine ar chórais ísealchumhachta, mar ríomhairí boird aonair nó sean ríomhairí glúine.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### INSTANCE पर आपका स्वागत है
|
### INSTANCE पर आपका स्वागत है
|
||||||
यह एक एक्टिविटीपब सर्वर है जो कम पावर सिस्टम पर सिंगल बोर्ड कंप्यूटर या पुराने लैपटॉप जैसे कुछ लोगों की आसान सेल्फ-होस्टिंग के लिए बनाया गया है।
|
यह एक एक्टिविटीपब सर्वर है जो कम पावर सिस्टम पर सिंगल बोर्ड कंप्यूटर या पुराने लैपटॉप जैसे कुछ लोगों की आसान सेल्फ-होस्टिंग के लिए बनाया गया है।
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Benvenuto in INSTANCE
|
### Benvenuto in INSTANCE
|
||||||
Questo è un server ActivityPub progettato per un facile self-hosting di poche persone su sistemi a basso consumo, come computer a scheda singola o vecchi laptop.
|
Questo è un server ActivityPub progettato per un facile self-hosting di poche persone su sistemi a basso consumo, come computer a scheda singola o vecchi laptop.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### INSTANCEへようこそ
|
### INSTANCEへようこそ
|
||||||
これは、シングルボードコンピューターや古いラップトップなどの低電力システムで数人を簡単にセルフホスティングするために設計されたActivityPubサーバーです。
|
これは、シングルボードコンピューターや古いラップトップなどの低電力システムで数人を簡単にセルフホスティングするために設計されたActivityPubサーバーです。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Welcome
|

|
||||||
|
### Welcome
|
||||||
Epicyon is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops.
|
Epicyon is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops.
|
||||||
|
|
||||||
Run your own social network presence the way you want to, and say goodbye to Big Tech.
|
Run your own social network presence the way you want to, and say goodbye to Big Tech.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Bem-vindo a INSTANCE
|

|
||||||
|
### Bem-vindo a INSTANCE
|
||||||
Este é um servidor ActivityPub projetado para fácil auto-hospedagem de algumas pessoas em sistemas de baixo consumo de energia, como computadores de placa única ou laptops antigos.
|
Este é um servidor ActivityPub projetado para fácil auto-hospedagem de algumas pessoas em sistemas de baixo consumo de energia, como computadores de placa única ou laptops antigos.
|
||||||
|
|
||||||
Administre sua própria presença na rede social do jeito que você quiser e diga adeus à Big Tech.
|
Administre sua própria presença na rede social do jeito que você quiser e diga adeus à Big Tech.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### Добро пожаловать в INSTANCE
|
### Добро пожаловать в INSTANCE
|
||||||
Это сервер ActivityPub, предназначенный для простого самостоятельного размещения нескольких человек в системах с низким энергопотреблением, таких как одноплатные компьютеры или старые ноутбуки.
|
Это сервер ActivityPub, предназначенный для простого самостоятельного размещения нескольких человек в системах с низким энергопотреблением, таких как одноплатные компьютеры или старые ноутбуки.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|

|
||||||
### 欢迎来到INSTANCE
|
### 欢迎来到INSTANCE
|
||||||
这是一个ActivityPub服务器,设计用于在低功耗系统(例如单板计算机或旧笔记本电脑)上轻松实现一些人的自我托管。
|
这是一个ActivityPub服务器,设计用于在低功耗系统(例如单板计算机或旧笔记本电脑)上轻松实现一些人的自我托管。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,10 @@ a:focus {
|
||||||
background-color: var(--timeline-posts-background-color);
|
background-color: var(--timeline-posts-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container img.markdownImage {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.container img.timelineicon:hover {
|
.container img.timelineicon:hover {
|
||||||
filter: brightness(var(--icon-brightness-change));
|
filter: brightness(var(--icon-brightness-change));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,10 @@ img.avatar {
|
||||||
width: var(--welcome-avatar-width);
|
width: var(--welcome-avatar-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container img.markdownImage {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.container.next {
|
.container.next {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +196,7 @@ span.psw {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: var(--welcome-button-width);
|
width: var(--welcome-button-width);
|
||||||
font-size: var(--welcome-font-size);
|
font-size: var(--welcome-font-size);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +236,7 @@ span.psw {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: var(--welcome-button-width);
|
width: var(--welcome-button-width);
|
||||||
font-size: var(--welcome-font-size-mobile);
|
font-size: var(--welcome-font-size-mobile);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
tests.py
|
|
@ -3288,6 +3288,18 @@ def testMarkdownToHtml():
|
||||||
markdown = 'This is just plain text'
|
markdown = 'This is just plain text'
|
||||||
assert markdownToHtml(markdown) == markdown
|
assert markdownToHtml(markdown) == markdown
|
||||||
|
|
||||||
|
markdown = 'This is a quotation:\n' + \
|
||||||
|
'> Some quote or other'
|
||||||
|
assert markdownToHtml(markdown) == 'This is a quotation:<br>' + \
|
||||||
|
'<blockquote><i>Some quote or other</i></blockquote>'
|
||||||
|
|
||||||
|
markdown = 'This is a multi-line quotation:\n' + \
|
||||||
|
'> The first line\n' + \
|
||||||
|
'> The second line'
|
||||||
|
assert markdownToHtml(markdown) == \
|
||||||
|
'This is a multi-line quotation:<br>' + \
|
||||||
|
'<blockquote><i>The first line The second line</i></blockquote>'
|
||||||
|
|
||||||
markdown = 'This is **bold**'
|
markdown = 'This is **bold**'
|
||||||
assert markdownToHtml(markdown) == 'This is <b>bold</b>'
|
assert markdownToHtml(markdown) == 'This is <b>bold</b>'
|
||||||
|
|
||||||
|
|
@ -3306,14 +3318,16 @@ def testMarkdownToHtml():
|
||||||
|
|
||||||
markdown = \
|
markdown = \
|
||||||
'This is [a link](https://something.somewhere) to something.\n' + \
|
'This is [a link](https://something.somewhere) to something.\n' + \
|
||||||
'And [something else](https://cat.pic).'
|
'And [something else](https://cat.pic).\n' + \
|
||||||
|
'Or .'
|
||||||
assert markdownToHtml(markdown) == \
|
assert markdownToHtml(markdown) == \
|
||||||
'This is <a href="https://something.somewhere" ' + \
|
'This is <a href="https://something.somewhere" ' + \
|
||||||
'target="_blank" rel="nofollow noopener noreferrer">' + \
|
'target="_blank" rel="nofollow noopener noreferrer">' + \
|
||||||
'a link</a> to something.<br>' + \
|
'a link</a> to something.<br>' + \
|
||||||
'And <a href="https://cat.pic" ' + \
|
'And <a href="https://cat.pic" ' + \
|
||||||
'target="_blank" rel="nofollow noopener noreferrer">' + \
|
'target="_blank" rel="nofollow noopener noreferrer">' + \
|
||||||
'something else</a>.'
|
'something else</a>.<br>' + \
|
||||||
|
'Or <img class="markdownImage" src="/cat.jpg" alt="pounce" />.'
|
||||||
|
|
||||||
|
|
||||||
def runAllTests():
|
def runAllTests():
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
2
utils.py
|
|
@ -1274,7 +1274,7 @@ def _isReservedName(nickname: str) -> bool:
|
||||||
'accounts', 'channels', 'profile', 'u',
|
'accounts', 'channels', 'profile', 'u',
|
||||||
'updates', 'repeat', 'announce',
|
'updates', 'repeat', 'announce',
|
||||||
'shares', 'fonts', 'icons', 'avatars',
|
'shares', 'fonts', 'icons', 'avatars',
|
||||||
'welcome')
|
'welcome', 'helpimages')
|
||||||
if nickname in reservedNames:
|
if nickname in reservedNames:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@ __status__ = "Production"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from shutil import copyfile
|
||||||
|
from utils import dangerousMarkup
|
||||||
from utils import getConfigParam
|
from utils import getConfigParam
|
||||||
from utils import getFullDomain
|
from utils import getFullDomain
|
||||||
from utils import isEditor
|
from utils import isEditor
|
||||||
from utils import removeIdEnding
|
from utils import removeIdEnding
|
||||||
from follow import followerApprovalActive
|
from follow import followerApprovalActive
|
||||||
from person import isPersonSnoozed
|
from person import isPersonSnoozed
|
||||||
|
from webapp_utils import markdownToHtml
|
||||||
from webapp_utils import htmlKeyboardNavigation
|
from webapp_utils import htmlKeyboardNavigation
|
||||||
from webapp_utils import htmlHideFromScreenReader
|
from webapp_utils import htmlHideFromScreenReader
|
||||||
from webapp_utils import htmlPostSeparator
|
from webapp_utils import htmlPostSeparator
|
||||||
|
|
@ -42,6 +45,42 @@ def _logTimelineTiming(enableTimingLog: bool, timelineStartTime,
|
||||||
boxName + ' ' + debugId + ' = ' + str(timeDiff))
|
boxName + ' ' + debugId + ' = ' + str(timeDiff))
|
||||||
|
|
||||||
|
|
||||||
|
def _getHelpForTimeline(baseDir: str, boxName: str) -> str:
|
||||||
|
"""Shows help text for the given timeline
|
||||||
|
"""
|
||||||
|
# get the filename for help for this timeline
|
||||||
|
helpFilename = baseDir + '/accounts/help_' + boxName + '.md'
|
||||||
|
if not os.path.isfile(helpFilename):
|
||||||
|
language = \
|
||||||
|
getConfigParam(baseDir, 'language')
|
||||||
|
if not language:
|
||||||
|
language = 'en'
|
||||||
|
defaultFilename = \
|
||||||
|
baseDir + '/defaultwelcome/' + \
|
||||||
|
'help_' + boxName + '_' + language + '.md'
|
||||||
|
if not os.path.isfile(defaultFilename):
|
||||||
|
defaultFilename = \
|
||||||
|
baseDir + '/defaultwelcome/help_' + boxName + '_en.md'
|
||||||
|
if os.path.isfile(defaultFilename):
|
||||||
|
copyfile(defaultFilename, helpFilename)
|
||||||
|
|
||||||
|
# show help text
|
||||||
|
if os.path.isfile(helpFilename):
|
||||||
|
instanceTitle = \
|
||||||
|
getConfigParam(baseDir, 'instanceTitle')
|
||||||
|
if not instanceTitle:
|
||||||
|
instanceTitle = 'Epicyon'
|
||||||
|
with open(helpFilename, 'r') as helpFile:
|
||||||
|
helpText = helpFile.read()
|
||||||
|
if dangerousMarkup(helpText, False):
|
||||||
|
return ''
|
||||||
|
helpText = helpText.replace('INSTANCE', instanceTitle)
|
||||||
|
return '<div class="container">\n' + \
|
||||||
|
markdownToHtml(helpText) + '\n' + \
|
||||||
|
'</div>\n'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def htmlTimeline(cssCache: {}, defaultTimeline: str,
|
def htmlTimeline(cssCache: {}, defaultTimeline: str,
|
||||||
recentPostsCache: {}, maxRecentPosts: int,
|
recentPostsCache: {}, maxRecentPosts: int,
|
||||||
translate: {}, pageNumber: int,
|
translate: {}, pageNumber: int,
|
||||||
|
|
@ -698,6 +737,8 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str,
|
||||||
translate['Page down'] + '"></a>\n' + \
|
translate['Page down'] + '"></a>\n' + \
|
||||||
' </center>\n'
|
' </center>\n'
|
||||||
tlStr += textModeSeparator
|
tlStr += textModeSeparator
|
||||||
|
elif itemCtr == 0:
|
||||||
|
tlStr += _getHelpForTimeline(baseDir, boxName)
|
||||||
|
|
||||||
# end of timeline-posts
|
# end of timeline-posts
|
||||||
tlStr += ' </div>\n'
|
tlStr += ' </div>\n'
|
||||||
|
|
@ -788,6 +829,7 @@ def _htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int,
|
||||||
' </center>\n'
|
' </center>\n'
|
||||||
|
|
||||||
separatorStr = htmlPostSeparator(baseDir, None)
|
separatorStr = htmlPostSeparator(baseDir, None)
|
||||||
|
ctr = 0
|
||||||
for published, item in sharesJson.items():
|
for published, item in sharesJson.items():
|
||||||
showContactButton = False
|
showContactButton = False
|
||||||
if item['actor'] != actor:
|
if item['actor'] != actor:
|
||||||
|
|
@ -799,6 +841,10 @@ def _htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int,
|
||||||
htmlIndividualShare(actor, item, translate,
|
htmlIndividualShare(actor, item, translate,
|
||||||
showContactButton, showRemoveButton)
|
showContactButton, showRemoveButton)
|
||||||
timelineStr += separatorStr
|
timelineStr += separatorStr
|
||||||
|
ctr += 1
|
||||||
|
|
||||||
|
if ctr == 0:
|
||||||
|
timelineStr += _getHelpForTimeline(baseDir, 'tlshares')
|
||||||
|
|
||||||
if not lastPage:
|
if not lastPage:
|
||||||
timelineStr += \
|
timelineStr += \
|
||||||
|
|
|
||||||
|
|
@ -66,31 +66,88 @@ def _markdownEmphasisHtml(markdown: str) -> str:
|
||||||
return markdown
|
return markdown
|
||||||
|
|
||||||
|
|
||||||
def markdownToHtml(markdown: str) -> str:
|
def _markdownReplaceQuotes(markdown: str) -> str:
|
||||||
"""Converts markdown formatted text to html
|
"""Replaces > quotes with html blockquote
|
||||||
|
"""
|
||||||
|
if '> ' not in markdown:
|
||||||
|
return markdown
|
||||||
|
lines = markdown.split('\n')
|
||||||
|
result = ''
|
||||||
|
prevQuoteLine = None
|
||||||
|
for line in lines:
|
||||||
|
if '> ' not in line:
|
||||||
|
result += line + '\n'
|
||||||
|
prevQuoteLine = None
|
||||||
|
continue
|
||||||
|
lineStr = line.strip()
|
||||||
|
if not lineStr.startswith('> '):
|
||||||
|
result += line + '\n'
|
||||||
|
prevQuoteLine = None
|
||||||
|
continue
|
||||||
|
lineStr = lineStr.replace('> ', '', 1).strip()
|
||||||
|
if prevQuoteLine:
|
||||||
|
newPrevLine = prevQuoteLine.replace('</i></blockquote>\n', '')
|
||||||
|
result = result.replace(prevQuoteLine, newPrevLine) + ' '
|
||||||
|
lineStr += '</i></blockquote>\n'
|
||||||
|
else:
|
||||||
|
lineStr = '<blockquote><i>' + lineStr + '</i></blockquote>\n'
|
||||||
|
result += lineStr
|
||||||
|
prevQuoteLine = lineStr
|
||||||
|
|
||||||
|
if '</blockquote>\n' in result:
|
||||||
|
result = result.replace('</blockquote>\n', '</blockquote>')
|
||||||
|
|
||||||
|
if result.endswith('\n') and \
|
||||||
|
not markdown.endswith('\n'):
|
||||||
|
result = result[:len(result) - 1]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _markdownReplaceLinks(markdown: str, images=False) -> str:
|
||||||
|
"""Replaces markdown links with html
|
||||||
|
Optionally replace image links
|
||||||
"""
|
"""
|
||||||
markdown = _markdownEmphasisHtml(markdown)
|
|
||||||
# replace markdown style links with html links
|
|
||||||
replaceLinks = {}
|
replaceLinks = {}
|
||||||
text = markdown
|
text = markdown
|
||||||
while '[' in text:
|
startChars = '['
|
||||||
|
if images:
|
||||||
|
startChars = '!['
|
||||||
|
while startChars in text:
|
||||||
if ')' not in text:
|
if ')' not in text:
|
||||||
break
|
break
|
||||||
text = text.split('[', 1)[1]
|
text = text.split(startChars, 1)[1]
|
||||||
markdownLink = '[' + text.split(')')[0] + ')'
|
markdownLink = startChars + text.split(')')[0] + ')'
|
||||||
if ']' not in markdownLink or \
|
if ']' not in markdownLink or \
|
||||||
'(' not in markdownLink:
|
'(' not in markdownLink:
|
||||||
text = text.split(')', 1)[1]
|
text = text.split(')', 1)[1]
|
||||||
continue
|
continue
|
||||||
replaceLinks[markdownLink] = \
|
if not images:
|
||||||
'<a href="' + \
|
replaceLinks[markdownLink] = \
|
||||||
markdownLink.split('(')[1].split(')')[0] + \
|
'<a href="' + \
|
||||||
'" target="_blank" rel="nofollow noopener noreferrer">' + \
|
markdownLink.split('(')[1].split(')')[0] + \
|
||||||
markdownLink.split('[')[1].split(']')[0] + \
|
'" target="_blank" rel="nofollow noopener noreferrer">' + \
|
||||||
'</a>'
|
markdownLink.split(startChars)[1].split(']')[0] + \
|
||||||
|
'</a>'
|
||||||
|
else:
|
||||||
|
replaceLinks[markdownLink] = \
|
||||||
|
'<img class="markdownImage" src="' + \
|
||||||
|
markdownLink.split('(')[1].split(')')[0] + \
|
||||||
|
'" alt="' + \
|
||||||
|
markdownLink.split(startChars)[1].split(']')[0] + \
|
||||||
|
'" />'
|
||||||
text = text.split(')', 1)[1]
|
text = text.split(')', 1)[1]
|
||||||
for mdLink, htmlLink in replaceLinks.items():
|
for mdLink, htmlLink in replaceLinks.items():
|
||||||
markdown = markdown.replace(mdLink, htmlLink)
|
markdown = markdown.replace(mdLink, htmlLink)
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
|
||||||
|
def markdownToHtml(markdown: str) -> str:
|
||||||
|
"""Converts markdown formatted text to html
|
||||||
|
"""
|
||||||
|
markdown = _markdownReplaceQuotes(markdown)
|
||||||
|
markdown = _markdownEmphasisHtml(markdown)
|
||||||
|
markdown = _markdownReplaceLinks(markdown, True)
|
||||||
|
markdown = _markdownReplaceLinks(markdown)
|
||||||
|
|
||||||
# replace headers
|
# replace headers
|
||||||
linesList = markdown.split('\n')
|
linesList = markdown.split('\n')
|
||||||
|
|
|
||||||