From 810f4986c96ac28d5908163004f8b5bf9b9306bb Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 15:58:54 +0100 Subject: [PATCH 01/20] Simplify the main new post construction function --- posts.py | 301 +++++++++++++++++++++++++++---------------------------- 1 file changed, 145 insertions(+), 156 deletions(-) diff --git a/posts.py b/posts.py index 22bb0cf4a..4376684d9 100644 --- a/posts.py +++ b/posts.py @@ -762,50 +762,6 @@ def _addSchedulePost(baseDir: str, nickname: str, domain: str, scheduleFile.write(indexStr + '\n') -def _appendEventFields(newPost: {}, - eventUUID: str, eventStatus: str, - anonymousParticipationEnabled: bool, - repliesModerationOption: str, - category: str, - joinMode: str, - eventDateStr: str, - endDateStr: str, - location: str, - maximumAttendeeCapacity: int, - ticketUrl: str, - subject: str) -> None: - """Appends Mobilizon-type event fields to a post - """ - if not eventUUID: - return - - # add attributes for Mobilizon-type events - newPost['uuid'] = eventUUID - if eventStatus: - newPost['ical:status'] = eventStatus - if anonymousParticipationEnabled: - newPost['anonymousParticipationEnabled'] = \ - anonymousParticipationEnabled - if repliesModerationOption: - newPost['repliesModerationOption'] = repliesModerationOption - if category: - newPost['category'] = category - if joinMode: - newPost['joinMode'] = joinMode - newPost['startTime'] = eventDateStr - newPost['endTime'] = endDateStr - if location: - newPost['location'] = location - if maximumAttendeeCapacity: - newPost['maximumAttendeeCapacity'] = maximumAttendeeCapacity - if ticketUrl: - newPost['ticketUrl'] = ticketUrl - if subject: - newPost['name'] = subject - newPost['summary'] = None - newPost['sensitive'] = False - - def validContentWarning(cw: str) -> str: """Returns a validated content warning """ @@ -876,6 +832,131 @@ def _createPostCWFromReply(baseDir: str, nickname: str, domain: str, return sensitive, summary +def _createPostS2S(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, content: str, statusNumber: str, + published: str, newPostId: str, postContext: {}, + toRecipients: [], toCC: [], inReplyTo: str, + sensitive: bool, commentsEnabled: bool, + tags: {}, attachImageFilename: str, + mediaType: str, imageDescription: str, city: str, + postObjectType: str, summary: str, + inReplyToAtomUri: str) -> {}: + """Creates a new server-to-server post + """ + actorUrl = httpPrefix + '://' + domain + '/users/' + nickname + idStr = \ + httpPrefix + '://' + domain + '/users/' + nickname + \ + '/statuses/' + statusNumber + '/replies' + newPostUrl = \ + httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber + newPostAttributedTo = \ + httpPrefix + '://' + domain + '/users/' + nickname + newPost = { + '@context': postContext, + 'id': newPostId + '/activity', + 'type': 'Create', + 'actor': actorUrl, + 'published': published, + 'to': toRecipients, + 'cc': toCC, + 'object': { + 'id': newPostId, + 'type': postObjectType, + 'summary': summary, + 'inReplyTo': inReplyTo, + 'published': published, + 'url': newPostUrl, + 'attributedTo': newPostAttributedTo, + 'to': toRecipients, + 'cc': toCC, + 'sensitive': sensitive, + 'atomUri': newPostId, + 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, + 'rejectReplies': not commentsEnabled, + 'mediaType': 'text/html', + 'content': content, + 'contentMap': { + 'en': content + }, + 'attachment': [], + 'tag': tags, + 'replies': { + 'id': idStr, + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'partOf': idStr, + 'items': [] + } + } + } + } + if attachImageFilename: + newPost['object'] = \ + attachMedia(baseDir, httpPrefix, nickname, domain, port, + newPost['object'], attachImageFilename, + mediaType, imageDescription, city) + return newPost + + +def _createPostC2S(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, content: str, statusNumber: str, + published: str, newPostId: str, postContext: {}, + toRecipients: [], toCC: [], inReplyTo: str, + sensitive: bool, commentsEnabled: bool, + tags: {}, attachImageFilename: str, + mediaType: str, imageDescription: str, city: str, + postObjectType: str, summary: str, + inReplyToAtomUri: str) -> {}: + """Creates a new client-to-server post + """ + idStr = \ + httpPrefix + '://' + domain + '/users/' + nickname + \ + '/statuses/' + statusNumber + '/replies' + newPostUrl = \ + httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber + newPost = { + "@context": postContext, + 'id': newPostId, + 'type': postObjectType, + 'summary': summary, + 'inReplyTo': inReplyTo, + 'published': published, + 'url': newPostUrl, + 'attributedTo': httpPrefix + '://' + domain + '/users/' + nickname, + 'to': toRecipients, + 'cc': toCC, + 'sensitive': sensitive, + 'atomUri': newPostId, + 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, + 'rejectReplies': not commentsEnabled, + 'mediaType': 'text/html', + 'content': content, + 'contentMap': { + 'en': content + }, + 'attachment': [], + 'tag': tags, + 'replies': { + 'id': idStr, + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'partOf': idStr, + 'items': [] + } + } + } + if attachImageFilename: + newPost = \ + attachMedia(baseDir, httpPrefix, nickname, domain, port, + newPost, attachImageFilename, + mediaType, imageDescription, city) + return newPost + + def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, toUrl: str, ccUrl: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, @@ -1059,119 +1140,27 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, postObjectType = 'Article' if not clientToServer: - actorUrl = httpPrefix + '://' + domain + '/users/' + nickname - - idStr = \ - httpPrefix + '://' + domain + '/users/' + nickname + \ - '/statuses/' + statusNumber + '/replies' - newPostUrl = \ - httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber - newPostAttributedTo = \ - httpPrefix + '://' + domain + '/users/' + nickname - newPost = { - '@context': postContext, - 'id': newPostId + '/activity', - 'type': 'Create', - 'actor': actorUrl, - 'published': published, - 'to': toRecipients, - 'cc': toCC, - 'object': { - 'id': newPostId, - 'type': postObjectType, - 'summary': summary, - 'inReplyTo': inReplyTo, - 'published': published, - 'url': newPostUrl, - 'attributedTo': newPostAttributedTo, - 'to': toRecipients, - 'cc': toCC, - 'sensitive': sensitive, - 'atomUri': newPostId, - 'inReplyToAtomUri': inReplyToAtomUri, - 'commentsEnabled': commentsEnabled, - 'rejectReplies': not commentsEnabled, - 'mediaType': 'text/html', - 'content': content, - 'contentMap': { - 'en': content - }, - 'attachment': [], - 'tag': tags, - 'replies': { - 'id': idStr, - 'type': 'Collection', - 'first': { - 'type': 'CollectionPage', - 'partOf': idStr, - 'items': [] - } - } - } - } - if attachImageFilename: - newPost['object'] = \ - attachMedia(baseDir, httpPrefix, nickname, domain, port, - newPost['object'], attachImageFilename, - mediaType, imageDescription, city) - _appendEventFields(newPost['object'], eventUUID, eventStatus, - anonymousParticipationEnabled, - repliesModerationOption, - category, joinMode, - eventDateStr, endDateStr, - location, maximumAttendeeCapacity, - ticketUrl, subject) + newPost = \ + _createPostS2S(baseDir, nickname, domain, port, + httpPrefix, content, statusNumber, + published, newPostId, postContext, + toRecipients, toCC, inReplyTo, + sensitive, commentsEnabled, + tags, attachImageFilename, + mediaType, imageDescription, city, + postObjectType, summary, + inReplyToAtomUri) else: - idStr = \ - httpPrefix + '://' + domain + '/users/' + nickname + \ - '/statuses/' + statusNumber + '/replies' - newPostUrl = \ - httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber - newPost = { - "@context": postContext, - 'id': newPostId, - 'type': postObjectType, - 'summary': summary, - 'inReplyTo': inReplyTo, - 'published': published, - 'url': newPostUrl, - 'attributedTo': httpPrefix + '://' + domain + '/users/' + nickname, - 'to': toRecipients, - 'cc': toCC, - 'sensitive': sensitive, - 'atomUri': newPostId, - 'inReplyToAtomUri': inReplyToAtomUri, - 'commentsEnabled': commentsEnabled, - 'rejectReplies': not commentsEnabled, - 'mediaType': 'text/html', - 'content': content, - 'contentMap': { - 'en': content - }, - 'attachment': [], - 'tag': tags, - 'replies': { - 'id': idStr, - 'type': 'Collection', - 'first': { - 'type': 'CollectionPage', - 'partOf': idStr, - 'items': [] - } - } - } - if attachImageFilename: - newPost = \ - attachMedia(baseDir, httpPrefix, nickname, domain, port, - newPost, attachImageFilename, - mediaType, imageDescription, city) - _appendEventFields(newPost, eventUUID, eventStatus, - anonymousParticipationEnabled, - repliesModerationOption, - category, joinMode, - eventDateStr, endDateStr, - location, maximumAttendeeCapacity, - ticketUrl, subject) + newPost = \ + _createPostC2S(baseDir, nickname, domain, port, + httpPrefix, content, statusNumber, + published, newPostId, postContext, + toRecipients, toCC, inReplyTo, + sensitive, commentsEnabled, + tags, attachImageFilename, + mediaType, imageDescription, city, + postObjectType, summary, + inReplyToAtomUri) if ccUrl: if len(ccUrl) > 0: newPost['cc'] = [ccUrl] From 3d67188634f80f63a04a3cc61a37b17218165c19 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 15:59:45 +0100 Subject: [PATCH 02/20] tags is a list --- posts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/posts.py b/posts.py index 4376684d9..c887e149e 100644 --- a/posts.py +++ b/posts.py @@ -837,7 +837,7 @@ def _createPostS2S(baseDir: str, nickname: str, domain: str, port: int, published: str, newPostId: str, postContext: {}, toRecipients: [], toCC: [], inReplyTo: str, sensitive: bool, commentsEnabled: bool, - tags: {}, attachImageFilename: str, + tags: [], attachImageFilename: str, mediaType: str, imageDescription: str, city: str, postObjectType: str, summary: str, inReplyToAtomUri: str) -> {}: @@ -905,7 +905,7 @@ def _createPostC2S(baseDir: str, nickname: str, domain: str, port: int, published: str, newPostId: str, postContext: {}, toRecipients: [], toCC: [], inReplyTo: str, sensitive: bool, commentsEnabled: bool, - tags: {}, attachImageFilename: str, + tags: [], attachImageFilename: str, mediaType: str, imageDescription: str, city: str, postObjectType: str, summary: str, inReplyToAtomUri: str) -> {}: From 8d4879818a116c0e4ebd5709a6429b5da846900e Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 16:48:02 +0100 Subject: [PATCH 03/20] Separate function for adding place and time to new post --- posts.py | 110 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 47 deletions(-) diff --git a/posts.py b/posts.py index c887e149e..ab14d9276 100644 --- a/posts.py +++ b/posts.py @@ -957,6 +957,64 @@ def _createPostC2S(baseDir: str, nickname: str, domain: str, port: int, return newPost +def _createPostPlaceAndTime(eventDate: str, endDate: str, + eventTime: str, endTime: str, + summary: str, content: str, + schedulePost: bool, + eventUUID: str, + location: str, + tags: []) -> str: + """Adds a place and time to the tags on a new post + """ + endDateStr = None + if endDate: + eventName = summary + if not eventName: + eventName = content + endDateStr = endDate + if endTime: + if endTime.endswith('Z'): + endDateStr = endDate + 'T' + endTime + else: + endDateStr = endDate + 'T' + endTime + \ + ':00' + strftime("%z", gmtime()) + else: + endDateStr = endDate + 'T12:00:00Z' + + # get the starting date and time + eventDateStr = None + if eventDate: + eventName = summary + if not eventName: + eventName = content + eventDateStr = eventDate + if eventTime: + if eventTime.endswith('Z'): + eventDateStr = eventDate + 'T' + eventTime + else: + eventDateStr = eventDate + 'T' + eventTime + \ + ':00' + strftime("%z", gmtime()) + else: + eventDateStr = eventDate + 'T12:00:00Z' + if not endDateStr: + endDateStr = eventDateStr + if not schedulePost and not eventUUID: + tags.append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Event", + "name": eventName, + "startTime": eventDateStr, + "endTime": endDateStr + }) + if location and not eventUUID: + tags.append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Place", + "name": location + }) + return eventDateStr + + def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, toUrl: str, ccUrl: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, @@ -1061,53 +1119,11 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, _createPostCWFromReply(baseDir, nickname, domain, inReplyTo, sensitive, summary) - # get the ending date and time - endDateStr = None - if endDate: - eventName = summary - if not eventName: - eventName = content - endDateStr = endDate - if endTime: - if endTime.endswith('Z'): - endDateStr = endDate + 'T' + endTime - else: - endDateStr = endDate + 'T' + endTime + \ - ':00' + strftime("%z", gmtime()) - else: - endDateStr = endDate + 'T12:00:00Z' - - # get the starting date and time - eventDateStr = None - if eventDate: - eventName = summary - if not eventName: - eventName = content - eventDateStr = eventDate - if eventTime: - if eventTime.endswith('Z'): - eventDateStr = eventDate + 'T' + eventTime - else: - eventDateStr = eventDate + 'T' + eventTime + \ - ':00' + strftime("%z", gmtime()) - else: - eventDateStr = eventDate + 'T12:00:00Z' - if not endDateStr: - endDateStr = eventDateStr - if not schedulePost and not eventUUID: - tags.append({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Event", - "name": eventName, - "startTime": eventDateStr, - "endTime": endDateStr - }) - if location and not eventUUID: - tags.append({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Place", - "name": location - }) + eventDateStr = \ + _createPostPlaceAndTime(eventDate, endDate, + eventTime, endTime, + summary, content, schedulePost, + eventUUID, location, tags) postContext = [ 'https://www.w3.org/ns/activitystreams', From 9907ad65f17180b061c92cd0dcabdcc2a753d255 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 17:12:10 +0100 Subject: [PATCH 04/20] Moderation report for new post --- posts.py | 70 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/posts.py b/posts.py index ab14d9276..8280ccc8c 100644 --- a/posts.py +++ b/posts.py @@ -1015,6 +1015,49 @@ def _createPostPlaceAndTime(eventDate: str, endDate: str, return eventDateStr +def _createPostMentions(ccUrl: str, newPost: {}, + toRecipients: [], tags: []) -> None: + """Updates mentions for a new post + """ + if not ccUrl: + return + if len(ccUrl) == 0: + return + newPost['cc'] = [ccUrl] + if newPost.get('object'): + newPost['object']['cc'] = [ccUrl] + + # if this is a public post then include any mentions in cc + toCC = newPost['object']['cc'] + if len(toRecipients) != 1: + return + if toRecipients[0].endswith('#Public') and \ + ccUrl.endswith('/followers'): + for tag in tags: + if tag['type'] != 'Mention': + continue + if tag['href'] not in toCC: + newPost['object']['cc'].append(tag['href']) + + +def _createPostModReport(baseDir: str, + isModerationReport: bool, newPost: {}, + newPostId: str) -> None: + """ if this is a moderation report then add a status + """ + if not isModerationReport: + return + # add status + if newPost.get('object'): + newPost['object']['moderationStatus'] = 'pending' + else: + newPost['moderationStatus'] = 'pending' + # save to index file + moderationIndexFile = baseDir + '/accounts/moderation.txt' + with open(moderationIndexFile, 'a+') as modFile: + modFile.write(newPostId + '\n') + + def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, toUrl: str, ccUrl: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, @@ -1177,33 +1220,10 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, mediaType, imageDescription, city, postObjectType, summary, inReplyToAtomUri) - if ccUrl: - if len(ccUrl) > 0: - newPost['cc'] = [ccUrl] - if newPost.get('object'): - newPost['object']['cc'] = [ccUrl] - # if this is a public post then include any mentions in cc - toCC = newPost['object']['cc'] - if len(toRecipients) == 1: - if toRecipients[0].endswith('#Public') and \ - ccUrl.endswith('/followers'): - for tag in tags: - if tag['type'] == 'Mention': - if tag['href'] not in toCC: - toCC.append(tag['href']) + _createPostMentions(ccUrl, newPost, toRecipients, tags) - # if this is a moderation report then add a status - if isModerationReport: - # add status - if newPost.get('object'): - newPost['object']['moderationStatus'] = 'pending' - else: - newPost['moderationStatus'] = 'pending' - # save to index file - moderationIndexFile = baseDir + '/accounts/moderation.txt' - with open(moderationIndexFile, 'a+') as modFile: - modFile.write(newPostId + '\n') + _createPostModReport(baseDir, isModerationReport, newPost, newPostId) # If a patch has been posted - i.e. the output from # git format-patch - then convert the activitypub type From cc4918f08167b309bf05967c1dff4f1d825ba6c4 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 17:17:43 +0100 Subject: [PATCH 05/20] Create default variables --- webapp_profile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapp_profile.py b/webapp_profile.py index 28247ded8..721deabd0 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1054,6 +1054,8 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, ssbAddress = '' blogAddress = '' toxAddress = '' + jamiAddress = '' + cwtchAddress = '' briarAddress = '' manuallyApprovesFollowers = '' movedTo = '' From de56a843cb037b8045134aa8e893b5fa20b3cbba Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 17:22:55 +0100 Subject: [PATCH 06/20] Fewer lines --- webapp_profile.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/webapp_profile.py b/webapp_profile.py index 721deabd0..3439a4fd7 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1034,31 +1034,15 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, bannerFile, bannerFilename = \ getBannerFile(baseDir, nickname, domain, theme) - isBot = '' - isGroup = '' - followDMs = '' - removeTwitter = '' - notifyLikes = '' - hideLikeButton = '' - mediaInstanceStr = '' - blogsInstanceStr = '' - newsInstanceStr = '' displayNickname = nickname - bioStr = '' - donateUrl = '' - emailAddress = '' - PGPpubKey = '' - PGPfingerprint = '' - xmppAddress = '' - matrixAddress = '' - ssbAddress = '' - blogAddress = '' - toxAddress = '' - jamiAddress = '' - cwtchAddress = '' - briarAddress = '' - manuallyApprovesFollowers = '' - movedTo = '' + isBot = isGroup = followDMs = removeTwitter = '' + notifyLikes = hideLikeButton = mediaInstanceStr = '' + blogsInstanceStr = newsInstanceStr = movedTo = '' + bioStr = donateUrl = emailAddress = PGPpubKey = '' + PGPfingerprint = xmppAddress = matrixAddress = '' + ssbAddress = blogAddress = toxAddress = jamiAddress = '' + cwtchAddress = briarAddress = manuallyApprovesFollowers = '' + actorJson = loadJson(actorFilename) if actorJson: if actorJson.get('movedTo'): From 48240a38bbf4262191114a0868f3550f906e3f3e Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 17:24:12 +0100 Subject: [PATCH 07/20] Fewer lines --- webapp_profile.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/webapp_profile.py b/webapp_profile.py index 3439a4fd7..60b285c29 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1096,22 +1096,19 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, if mediaInstance: if mediaInstance is True: mediaInstanceStr = 'checked' - blogsInstanceStr = '' - newsInstanceStr = '' + blogsInstanceStr = newsInstanceStr = '' newsInstance = getConfigParam(baseDir, "newsInstance") if newsInstance: if newsInstance is True: newsInstanceStr = 'checked' - blogsInstanceStr = '' - mediaInstanceStr = '' + blogsInstanceStr = mediaInstanceStr = '' blogsInstance = getConfigParam(baseDir, "blogsInstance") if blogsInstance: if blogsInstance is True: blogsInstanceStr = 'checked' - mediaInstanceStr = '' - newsInstanceStr = '' + mediaInstanceStr = newsInstanceStr = '' filterStr = '' filterFilename = \ From f62dc618184cdf4d34d1408e2ca02c90ff38ed41 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Sun, 27 Jun 2021 18:59:46 +0100 Subject: [PATCH 08/20] Themes dropdown function --- webapp_profile.py | 87 +++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/webapp_profile.py b/webapp_profile.py index 60b285c29..a56ebd0a3 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -1008,6 +1008,52 @@ def _htmlProfileShares(actor: str, translate: {}, return profileStr +def _grayscaleEnabled(baseDir: str) -> bool: + """Is grayscale UI enabled? + """ + return os.path.isfile(baseDir + '/accounts/.grayscale') + + +def _htmlThemesDropdown(baseDir: str, translate: {}) -> str: + """Returns the html for theme selection dropdown + """ + # Themes section + themes = getThemesList(baseDir) + themesDropdown = '
\n' + grayscale = '' + if _grayscaleEnabled(baseDir): + grayscale = 'checked' + themesDropdown += \ + ' ' + translate['Grayscale'] + '
' + themesDropdown += '
' + if os.path.isfile(baseDir + '/fonts/custom.woff') or \ + os.path.isfile(baseDir + '/fonts/custom.woff2') or \ + os.path.isfile(baseDir + '/fonts/custom.otf') or \ + os.path.isfile(baseDir + '/fonts/custom.ttf'): + themesDropdown += \ + ' ' + \ + translate['Remove the custom font'] + '
' + themeName = getConfigParam(baseDir, 'theme') + themesDropdown = \ + themesDropdown.replace('