Merge branch 'master' into dev/sync-nextcloud-addressbook

This commit is contained in:
Fahim Salam Chowdhury 2024-10-16 08:51:43 +00:00 committed by GitHub
commit 721a849254
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
122 changed files with 1816 additions and 1677 deletions

View file

@ -0,0 +1,13 @@
[SETTINGS_CHANGE_PASSWORD]
LEGEND_CHANGE_PASSWORD = "Змінити пароль"
LABEL_CURRENT_PASSWORD = "Поточний пароль"
LABEL_NEW_PASSWORD = "Новий пароль"
LABEL_REPEAT_PASSWORD = "Підтвердьте новий пароль"
BUTTON_UPDATE_PASSWORD = "Змінити"
ERROR_PASSWORD_MISMATCH = "Паролі не збігаються, спробуйте ще раз"
[NOTIFICATIONS]
COULD_NOT_SAVE_NEW_PASSWORD = "Не вдалося зберегти новий пароль"
CURRENT_PASSWORD_INCORRECT = "Поточний пароль не вірний"
NEW_PASSWORD_SHORT = "Пароль закороткий"
NEW_PASSWORD_WEAK = "Пароль надто легкий"
NEW_PASSWORD_HIBP = "Пароль знайдено в сервісі Have I Been Pwned"

View file

@ -1,3 +1,8 @@
/* Prevent collapse of empty elements so that the caret can be placed in there */
.CompactComposer *:empty::before {
content: "\200B";
}
.CompactComposer .squire-toolbar {
padding-top: 4px;
padding-bottom: 0;

View file

@ -6,8 +6,8 @@ class CompactComposerPlugin extends \RainLoop\Plugins\AbstractPlugin
NAME = 'Compact Composer',
AUTHOR = 'Sergey Mosin',
URL = 'https://github.com/the-djmaze/snappymail/pull/1466',
VERSION = '1.0.4',
RELEASE = '2024-04-23',
VERSION = '1.0.5',
RELEASE = '2024-08-08',
REQUIRED = '2.34.0',
LICENSE = 'AGPL v3',
DESCRIPTION = 'WYSIWYG editor with a compact toolbar';

View file

@ -17,18 +17,28 @@
addEventListener('rl-view-model', e => {
const vm = e.detail;
if ('PopupsCompose' === vm.viewModelTemplateID && rl.settings.get('editorWysiwyg') === 'CompactComposer') {
vm.querySelector('.tabs label[for="tab-body"]').dataset.bind = "visible: canMailvelope";
// add visible binding to the label
const bodyLabel = vm.querySelector('.tabs label[for="tab-body"]');
bodyLabel.dataset.bind = 'visible: canMailvelope';
// re-apply the binding
const labelNext = bodyLabel.nextElementSibling;
ko.removeNode(bodyLabel);
labelNext.parentElement.insertBefore(bodyLabel, labelNext);
ko.applyBindingAccessorsToNode(bodyLabel, null, vm);
// Now move the attachments tab to the bottom of the screen
const
input = vm.querySelector('.tabs input[value="attachments"]'),
label = vm.querySelector('.tabs label[for="tab-attachments"]'),
area = vm.querySelector('.tabs .attachmentAreaParent');
input.remove();
label.remove();
area.remove();
const area = vm.querySelector('.tabs .attachmentAreaParent');
vm.querySelector('.tabs input[value="attachments"]').remove();
vm.querySelector('.tabs label[for="tab-attachments"]').remove();
area.querySelector('.no-attachments-desc').remove();
area.classList.add('compact');
area.querySelector('.b-attachment-place').dataset.bind = "visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}";
vm.viewModelDom.append(area);
// Add and re-apply the bindings for the attachment-place
const place = area.querySelector('.b-attachment-place');
ko.removeNode(place);
area.insertBefore(place, area.firstElementChild);
place.dataset.bind = 'visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}';
ko.applyBindingAccessorsToNode(place, null, vm);
// There is a better way to do this probably,
// but we need this for drag and drop to work
e.detail.attachmentsArea = e.detail.bodyArea;
@ -36,10 +46,6 @@
});
const
removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE',
allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,EM,FONT,H1,H2,H3,H4,H5,H6,HR,I,IMG,LI,OL,P,SPAN,STRONG,TABLE,TD,TH,TR,U,UL',
allowedAttributes = 'abbr,align,background,bgcolor,border,cellpadding,cellspacing,class,color,colspan,dir,face,frame,height,href,hspace,id,lang,rowspan,rules,scope,size,src,style,target,type,usemap,valign,vspace,width'.split(','),
// TODO: labels translations
i18n = (str, def) => rl.i18n(str) || def,
@ -95,21 +101,7 @@
},
pasteSanitizer = (event) => {
const frag = event.detail.fragment;
frag.querySelectorAll('a:empty,span:empty').forEach(el => el.remove());
frag.querySelectorAll(removeElements).forEach(el => el.remove());
frag.querySelectorAll('*').forEach(el => {
if (!el.matches(allowedElements)) {
el.replaceWith(getFragmentOfChildren(el));
} else if (el.hasAttributes()) {
[...el.attributes].forEach(attr => {
let name = attr.name.toLowerCase();
if (!allowedAttributes.includes(name)) {
el.removeAttribute(name);
}
});
}
});
return rl.Utils.cleanHtml(event.detail.html).html;
},
pasteImageHandler = (e, squire) => {
@ -317,7 +309,8 @@
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + 'px';
clr.value = '';
// firefox does not call "onchange" for #000 if we use clr.value=''
clr.value = '#00ff0c';
clr.onchange = () => {
switch (name) {
case 'color':

View file

@ -12,6 +12,7 @@ class CustomLoginMappingPlugin extends \RainLoop\Plugins\AbstractPlugin
public function Init() : void
{
// Happens only at DoLogin | ServiceSso | DoAccountSetup
$this->addHook('login.credentials', 'FilterLoginCredentials');
}
@ -29,18 +30,14 @@ class CustomLoginMappingPlugin extends \RainLoop\Plugins\AbstractPlugin
if (!empty($sMapping)) {
$aLines = \explode("\n", \preg_replace('/[\r\n\t\s]+/', "\n", $sMapping));
foreach ($aLines as $sLine) {
if (false !== \strpos($sLine, ':')) {
$aData = \explode(':', $sLine, 3);
if (\is_array($aData) && !empty($aData[0]) && isset($aData[1])) {
$aData = \array_map('trim', $aData);
if ($sEmail === $aData[0]) {
if (\strlen($aData[1])) {
$sImapUser = $aData[1];
}
if (isset($aData[2]) && \strlen($aData[2])) {
$sSmtpUser = $aData[2];
}
}
$aData = \explode(':', $sLine, 3);
if (\is_array($aData) && isset($aData[1]) && \trim($aData[0]) === $sEmail) {
$aData = \array_map('trim', $aData);
if (\strlen($aData[1])) {
$sImapUser = $aData[1];
}
if (isset($aData[2]) && \strlen($aData[2])) {
$sSmtpUser = $aData[2];
}
}
}

View file

@ -1,11 +1,13 @@
(rl => {
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'),
// https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
query = rl.pluginSettingsGet('login-o365', 'personal') ? '' : '?',
tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
login = () => {
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({
response_type: 'code',
client_id: client_id,
redirect_uri: document.location.href.replace(/\/$/, '') + '/LoginO365',
redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365',
scope: [
// Associate personal info
'openid',

View file

@ -7,8 +7,9 @@
*
* https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps
*
* redirect_uri=https://{DOMAIN}/?LoginO365
* redirect_uri=https://{DOMAIN}/LoginO365
* https://learn.microsoft.com/en-us/entra/identity-platform/reply-url#query-parameter-support-in-redirect-uris
* Azure: redirect_uri=https://{DOMAIN}/?LoginO365
* Personal: redirect_uri=https://{DOMAIN}/LoginO365
*/
use RainLoop\Model\MainAccount;
@ -18,8 +19,8 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.2',
RELEASE = '2024-08-13',
VERSION = '0.3',
RELEASE = '2024-09-29',
REQUIRED = '2.36.1',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
@ -47,6 +48,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
public function httpPaths(array &$aPaths) : void
{
// Personal accounts workaround
if (!empty($_SERVER['PATH_INFO']) && \str_ends_with($_SERVER['PATH_INFO'], 'LoginO365')) {
$aPaths = ['LoginO365'];
}
@ -113,7 +115,7 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
$iExpires += $aResponse['expires_in'];
$oO365->setAccessToken($sAccessToken);
$aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo"');
$aUserInfo = $oO365->fetch('https://graph.microsoft.com/oidc/userinfo');
if (200 != $aUserInfo['code']) {
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
@ -154,6 +156,12 @@ class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('personal')
->SetLabel('Use with personal accounts')
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL)
->SetDefaultValue(true)
->SetAllowedInJs()
->SetDescription('Sign in users with personal Microsoft accounts such as Outlook.com (Hotmail)'),
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)

View file

@ -4,8 +4,8 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Nextcloud',
VERSION = '2.38.0',
RELEASE = '2024-09-16',
VERSION = '2.38.1',
RELEASE = '2024-10-08',
CATEGORY = 'Integrations',
DESCRIPTION = 'Integrate with Nextcloud v20+',
REQUIRED = '2.38.0';
@ -55,7 +55,6 @@ class NextcloudPlugin extends \RainLoop\Plugins\AbstractPlugin
$this->addTemplate('templates/PopupsNextcloudFiles.html');
$this->addTemplate('templates/PopupsNextcloudCalendars.html');
$this->addTemplate('templates/PopupsNextcloudInvites.html');
// $this->addHook('login.credentials.step-2', 'loginCredentials2');
// $this->addHook('login.credentials', 'loginCredentials');

View file

@ -21,18 +21,9 @@
// https://github.com/nextcloud/calendar/issues/4684
if (cfg.CalDAV) {
attachmentsControls.append(Element.fromHTML(`<span data-bind="visible: nextcloudICSShow" data-icon="📅">
attachmentsControls.append(Element.fromHTML(`<span data-bind="visible: nextcloudICS" data-icon="📅">
<span class="g-ui-link" data-bind="click: nextcloudSaveICS" data-i18n="NEXTCLOUD/SAVE_ICS"></span>
</span>`));
attachmentsControls.append(Element.fromHTML(`<span data-bind="visible: nextcloudICSOldInvitation" data-icon="📅">
<span data-i18n="NEXTCLOUD/OLD_INVITATION"></span>
</span>`))
attachmentsControls.append(Element.fromHTML(`<span data-bind="visible: nextcloudICSNewInvitation" data-icon="📅">
<span class="g-ui-link" data-bind="click: nextcloudSaveICS" data-i18n="NEXTCLOUD/UPDATE_ON_MY_CALENDAR"></span>
</span>`))
attachmentsControls.append(Element.fromHTML(`<span data-bind="visible: nextcloudICSLastInvitation" data-icon="📅">
<span data-i18n="NEXTCLOUD/LAST_INVITATION"></span>
</span>`))
}
}
/*
@ -116,169 +107,18 @@
};
view.nextcloudICS = ko.observable(null);
view.nextcloudICSOldInvitation = ko.observable(null);
view.nextcloudICSNewInvitation = ko.observable(null);
view.nextcloudICSLastInvitation = ko.observable(null);
view.nextcloudICSShow = ko.observable(null);
view.nextcloudICSCalendar = ko.observable(null);
view.filteredEventsUrls = ko.observable([]);
view.nextcloudSaveICS = async () => {
view.nextcloudSaveICS = () => {
let VEVENT = view.nextcloudICS();
VEVENT = await view.handleUpdatedRecurrentEvents(VEVENT)
VEVENT && rl.nextcloud.selectCalendar(VEVENT)
VEVENT && rl.nextcloud.selectCalendar()
.then(href => href && rl.nextcloud.calendarPut(href, VEVENT));
}
view.handleUpdatedRecurrentEvents = async (VEVENT) => {
const uid = VEVENT.UID
let makePUTRequest = false
const filteredEventUrls = view.filteredEventUrls
if (filteredEventUrls.length > 1) {
// console.warn('filteredEventUrls.length > 1')
}
else if (filteredEventUrls.length == 1) {
const eventUrl = filteredEventUrls[0]
const eventText = await fetchEvent(eventUrl)
// don't do anything for equal cards
if (VEVENT.rawText == eventText) {
return VEVENT
}
const newVeventsText = extractVEVENTs(VEVENT.rawText)
const oldVeventsText = extractVEVENTs(eventText)
// if there's only one event in the old card, it can't be an edit of the exception card
if (oldVeventsText.length == 1) {
const newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventsText[0])
// if there's a property RECURRENCE-ID in the new card, it's an recurrence exception event
if (newRecurrenceId.length > 0) {
const updatedVEVENT = {
'SUMMARY' : VEVENT.SUMMARY,
'UID' : VEVENT.UID,
'rawText' : mergeEventTexts(eventText, newVeventsText[0])
}
VEVENT = updatedVEVENT
makePUTRequest = true
}
else {
return VEVENT
}
}
// if there's more than one event in the old card, it's possible to be an inclusion of a new exception card
// or update of old exception card
else {
let recurrenceIdMatch = false
let isUpdate = false
const updateData = {
'oldEventIndex': null,
'newEventIndex': null,
}
// check if it's an update
for (let i = 0; i < oldVeventsText.length; i++) {
let oldVeventText = oldVeventsText[i]
for (let j = 0; j < newVeventsText.length; j++) {
let newVeventText = newVeventsText[j]
let oldRecurrenceId = extractProperties('RECURRENCE-ID', oldVeventText)
let newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventText)
let oldSequence = extractProperties('SEQUENCE', oldVeventText)
let newSequence = extractProperties('SEQUENCE', newVeventText)
if (oldRecurrenceId.length == 0 || newRecurrenceId.length == 0 || oldSequence.length == 0 || newSequence.length == 0) {
continue
}
if (oldRecurrenceId[0] == newRecurrenceId[0]) {
if (newSequence[0] > oldSequence[0]) {
isUpdate = true
updateData.oldEventIndex = i
updateData.newEventIndex = j
i = oldVeventsText.length
j = newVeventsText.length
}
}
}
}
// if it's an update...
if (isUpdate) {
// substitute old event text for new event text
const oldEventStart = eventText.indexOf(oldVeventsText[updateData.oldEventIndex])
const oldEventEnd = oldEventStart + oldVeventsText[updateData.oldEventIndex].length
const newEvent = eventText.substring(0, oldEventStart) + newVeventsText[updateData.newEventIndex] + eventText.substring(oldEventEnd)
const updatedVEVENT = {
'SUMMARY' : VEVENT.SUMMARY,
'UID': VEVENT.UID,
'rawText' : newEvent
}
VEVENT = updatedVEVENT
makePUTRequest = true
}
// if it's not an update, it's an inclusion, as there's no match of RECURRENCE-ID
else {
const updatedVEVENT = {
'SUMMARY' : VEVENT.SUMMARY,
'UID' : VEVENT.UID,
'rawText' : mergeEventTexts(eventText, newVeventsText[0])
}
VEVENT = updatedVEVENT
makePUTRequest = true
}
}
}
if (makePUTRequest) {
let href = "/" + (filteredEventUrls[0].split('/').slice(5, -1)[0])
rl.nextcloud.calendarPut(href, VEVENT, (response) => {
if (response.status != 201 && response.status != 204) {
InvitesPopupView.showModal([
rl.i18n('NEXTCLOUD/EVENT_UPDATE_FAILURE_TITLE'),
rl.i18n('NEXTCLOUD/EVENT_UPDATE_FAILURE_BODY', {eventName: VEVENT.SUMMARY})
])
return
}
InvitesPopupView.showModal([
rl.i18n('NEXTCLOUD/EVENT_UPDATED_TITLE'),
rl.i18n('NEXTCLOUD/EVENT_UPDATED_BODY', {eventName: VEVENT.SUMMARY})
])
})
return null
}
return VEVENT
}
/**
* TODO
*/
view.message.subscribe(msg => {
view.nextcloudICS(null);
view.nextcloudICSOldInvitation(null);
view.nextcloudICSNewInvitation(null);
view.nextcloudICSLastInvitation(null);
view.nextcloudICSShow(view.nextcloudICS())
if (msg && cfg.CalDAV) {
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
@ -286,7 +126,7 @@
// fetch it and parse the VEVENT
rl.fetch(ics.linkDownload())
.then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response })))
.then(async (text) => {
.then(text => {
let VEVENT,
VALARM,
multiple = ['ATTACH','ATTENDEE','CATEGORIES','COMMENT','CONTACT','EXDATE',
@ -337,158 +177,6 @@
shouldReply: VEVENT.shouldReply()
});
view.nextcloudICS(VEVENT);
view.nextcloudICSShow(true);
// try to get calendars, save
const calendarUrls = await fetchCalendarUrls()
const filteredEventUrls = []
for (let i = 0; i < calendarUrls.length; i++) {
let calendarUrl = calendarUrls[i]
const skipCalendars = ['/inbox/', '/outbox/', '/trashbin/']
let skip = false
for (let j = 0; j < skipCalendars.length; j++) {
if (calendarUrl.includes(skipCalendars[j])) {
skip = true
break
}
}
if (skip) {
continue
}
// try to get event
const eventUrls = await fetchEventUrl(calendarUrl, VEVENT.UID)
if (eventUrls.length == 0) {
continue
}
eventUrls.forEach((url) => {
filteredEventUrls.push(url)
})
}
view.filteredEventUrls = filteredEventUrls
// if there's none, save in view.nextcloudICS
if (filteredEventUrls.length == 0) {
view.nextcloudICS(VEVENT);
view.nextcloudICSShow(true)
}
// if there's some...
else {
const savedEvent = await fetchEvent(filteredEventUrls[0])
const newVeventsText = extractVEVENTs(VEVENT.rawText)
const oldVeventsText = extractVEVENTs(savedEvent)
// if there's more than one event in the old card, it's possible to be inclusion of new exception card
// or updated of old exception card
if (oldVeventsText.length > 1) {
let recurrenceIdMatch = false
let oldNewestCreated = null
let newNewestCreated = null
// check if it's an update
for (let i = 0; i < oldVeventsText.length; i++) {
let oldVeventText = oldVeventsText[i]
for (let j = 0; j < newVeventsText.length; j++) {
let newVeventText = newVeventsText[j]
let oldRecurrenceId = extractProperties('RECURRENCE-ID', oldVeventText)
let newRecurrenceId = extractProperties('RECURRENCE-ID', newVeventText)
let oldSequence = extractProperties('SEQUENCE', oldVeventText)
let newSequence = extractProperties('SEQUENCE', newVeventText)
if (newRecurrenceId.length == 0 && oldRecurrenceId.length == 1) {
view.nextcloudICSShow(false)
view.nextcloudICSOldInvitation(true)
}
if (oldRecurrenceId.length > 0 && newRecurrenceId.length > 0 && oldSequence.length > 0 && newSequence.length > 0) {
if (oldRecurrenceId[0] == newRecurrenceId[0]) {
if (newSequence[0] < oldSequence[0]) {
view.nextcloudICSOldInvitation(true)
view.nextcloudICSShow(false)
}
else if (newSequence[0] == oldSequence[0]) {
view.nextcloudICSLastInvitation(true)
view.nextcloudICSShow(false)
}
else if (newSequence[0] > oldSequence[0]) {
view.nextcloudICSNewInvitation(true)
view.nextcloudICSShow(false)
}
// exit for loops
j = newVeventsText.length
i = oldVeventsText.length
}
}
let oldCreated = extractProperties('CREATED', oldVeventText)
let newCreated = extractProperties('CREATED', newVeventText)
if (oldCreated.length == 0 || newCreated.length == 0) {
continue
}
const formattedOldDate = oldCreated[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z');
const formattedNewDate = newCreated[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z');
const oldDate = new Date(formattedOldDate)
const newDate = new Date(formattedNewDate)
if (oldNewestCreated == null || oldDate > oldNewestCreated) {
oldNewestCreated = oldDate
}
if (newNewestCreated == null || newDate > newNewestCreated) {
newNewestCreated = newDate
}
}
}
if (newNewestCreated != null && oldNewestCreated != null) {
if (newNewestCreated < oldNewestCreated) {
view.nextcloudICSOldInvitation(true)
view.nextcloudICSShow(false)
}
else if (newNewestCreated == oldNewestCreated) {
view.nextcloudICSLastInvitation(true)
view.nextcloudICSShow(false)
}
}
}
else {
const oldLastModified = extractProperties('LAST-MODIFIED', oldVeventsText[0])
const newLastModified = extractProperties('LAST-MODIFIED', newVeventsText[0])
if (oldLastModified.length == 1 && newLastModified.length == 1) {
const formattedOldDate = oldLastModified[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z');
const formattedNewDate = newLastModified[0].replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z');
const oldDate = new Date(formattedOldDate)
const newDate = new Date(formattedNewDate)
if (newDate > oldDate) {
view.nextcloudICSNewInvitation(true)
view.nextcloudICSShow(false)
}
else if (newDate < oldDate) {
view.nextcloudICSOldInvitation(true)
view.nextcloudICSShow(false)
}
else {
view.nextcloudICSLastInvitation(true)
view.nextcloudICSShow(false)
}
}
}
}
}
});
}
@ -498,148 +186,3 @@
});
})(window.rl);
async function fetchCalendarUrls () {
const username = OC.currentUser
const requestToken = OC.requestToken
const url = '/remote.php/dav/calendars/' + username
const response = await fetch(url, {
'method': 'PROPFIND',
'headers': {
'Depth': '1',
'Content-Type': 'application/xml',
'requesttoken' : requestToken
}
})
if (!response.ok) {
throw new Error('Error fetching calendars', response)
}
const responseText = await response.text()
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(responseText, 'application/xml')
const calendarUrls = []
const hrefElements = xmlDoc.getElementsByTagName('d:href')
for (let i = 1; i < hrefElements.length; i++) {
let calendarUrl = hrefElements[i].textContent.trim()
calendarUrls.push(calendarUrl)
}
return calendarUrls
}
async function fetchEventUrl (calendarUrl, uid) {
const requestToken = OC.requestToken
const xmlRequestBody = `<?xml version="1.0" encoding="UTF-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:prop>
<C:calendar-data/>
</C:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="UID">
<C:text-match collation="i;unicode-casemap" match-type="equals">${uid}</C:text-match>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>`
const response = await fetch(calendarUrl, {
'method': 'REPORT',
'headers': {
'Content-Type': 'application/xml',
'Depth': 1,
'requesttoken': requestToken
},
'body': xmlRequestBody
})
const responseText = await response.text()
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(responseText, 'application/xml')
const hrefElements = xmlDoc.getElementsByTagName('d:href')
const hrefValues = Array.from(hrefElements).map(element => element.textContent)
return hrefValues
}
async function fetchEvent (eventUrl) {
const requestToken = OC.requestToken
const response = await fetch(eventUrl, {
'method': 'GET',
'headers': {
'requesttoken': requestToken
}
})
const responseText = await response.text()
return responseText
}
function extractVEVENTs(text) {
let lastEndIndex = 0
const vEvents = []
let textToSearch = ""
let beginIndex = 0
let endIndex = 0
let foundVevent = false
while (true) {
textToSearch = text.substring(lastEndIndex)
beginIndex = textToSearch.indexOf('BEGIN:VEVENT')
if (beginIndex == -1) {
break
}
endIndex = textToSearch.substring(beginIndex).indexOf('END:VEVENT')
if (endIndex == -1) {
break
}
endIndex += beginIndex
lastEndIndex = lastEndIndex + endIndex + 'END:VEVENT'.length
foundVevent = textToSearch.substring(beginIndex + 'BEGIN:VEVENT'.length, endIndex)
vEvents.push(foundVevent)
}
return vEvents
}
function extractProperties (property, text) {
const matches = text.match(`${property}.*`)
if (matches == null) {
return []
}
const separatedMatches = matches.map((match) => {
return match.substring(property.length + 1)
})
return separatedMatches
}
function mergeEventTexts (oldEventText, newEventText) {
const appendIndex = oldEventText.indexOf('END:VEVENT') + 'END:VEVENT'.length
const updatedEventText = oldEventText.substring(0, appendIndex) + "\nBEGIN:VEVENT" + newEventText + "END:VEVENT" + oldEventText.substring(appendIndex)
return updatedEventText
}

View file

@ -320,28 +320,12 @@ class NextcloudCalendarsPopupView extends rl.pluginPopupView {
}
onBuild(dom) {
let modalObj = this
this.tree = dom.querySelector('#sm-nc-calendars');
this.tree.addEventListener('click', event => {
let el = event.target;
if (el.matches('button')) {
this.select = el.href;
let VEVENT = this.VEVENT
rl.nextcloud.calendarPut(this.select, this.VEVENT, (response) => {
if (response.status != 201 && response.status != 204) {
InvitesPopupView.showModal([
rl.i18n('MESSAGE/EVENT_ADDITION_FAILURE_TITLE'),
rl.i18n('MESSAGE/EVENT_ADDITION_FAILURE_BODY', {eventName: VEVENT.SUMMARY}),
() => {modalObj.close()}
])
}
InvitesPopupView.showModal([
rl.i18n('MESSAGE/EVENT_ADDED_TITLE'),
rl.i18n('MESSAGE/EVENT_ADDED_BODY', {eventName: VEVENT.SUMMARY}),
() => {modalObj.close()}
])
})
this.close();
}
});
}
@ -391,10 +375,9 @@ class NextcloudCalendarsPopupView extends rl.pluginPopupView {
treeElement.appendChild(li);
}
// Happens after showModal()
beforeShow(fResolve, VEVENT) {
beforeShow(fResolve) {
this.select = '';
this.fResolve = fResolve;
this.VEVENT = VEVENT
this.tree.innerHTML = '';
davFetch('calendars', '/', {
method: 'PROPFIND',
@ -449,15 +432,14 @@ close() {}
}
rl.nextcloud = {
selectCalendar: (VEVENT) =>
selectCalendar: () =>
new Promise(resolve => {
NextcloudCalendarsPopupView.showModal([
href => resolve(href),
VEVENT
]);
}),
calendarPut: (path, event, callback) => {
calendarPut: (path, event) => {
davFetch('calendars', path + '/' + event.UID + '.ics', {
method: 'PUT',
headers: {
@ -481,8 +463,6 @@ rl.nextcloud = {
// response.text().then(text => console.error({status:response.status, body:text}));
Promise.reject(new Error({ response }));
}
callback && callback(response)
});
},
@ -520,27 +500,3 @@ function getElementsInNamespaces(xmlDocument, tagName) {
}
return results;
}
class InvitesPopupView extends rl.pluginPopupView {
constructor() {
super('NextcloudInvites')
}
onBuild(dom) {
this.title = dom.querySelector('#sm-invites-popup-title')
this.body = dom.querySelector('#sm-invites-popup-body')
}
beforeShow(title, body, onHide_) {
this.title.innerHTML = title
this.body.innerHTML = body
this.onHide_ = onHide_
}
onHide() {
if (this.onHide_) {
this.onHide_()
}
}
}

View file

@ -2,21 +2,14 @@
"NEXTCLOUD": {
"SAVE_ATTACHMENTS": "Save in Nextcloud",
"SAVE_EML": "Save as .eml in Nextcloud",
"SAVE_ICS": "Add to calendar",
"SELECT_FOLDER": "Select folder",
"SELECT_FILES": "Select file(s)",
"ATTACH_FILES": "Attach Nextcloud files",
"SELECT_CALENDAR": "Select calendar",
"FILE_ATTACH": "attach",
"FILE_INTERNAL": "internal",
"FILE_PUBLIC": "public",
"ADDRESS_BOOK": "Address Book",
"SELECT_CALENDAR": "Select calendar",
"SAVE_ICS": "Add to calendar",
"OLD_INVITATION": "Old invitation",
"UPDATE_ON_MY_CALENDAR": "Update invitation",
"LAST_INVITATION": "Last invitation",
"EVENT_UPDATE_FAILURE_TITLE": "Update failed",
"EVENT_UPDATE_FAILURE_BODY": "Why, i have no clue",
"EVENT_UPDATED_TITLE": "Updated",
"EVENT_UPDATED_BODY": "Why, i have no clue"
"FILE_PUBLIC": "public"
}
}

View file

@ -1,8 +0,0 @@
<header>
<a class="close" href="#" data-bind="click: close">×</a>
<h3 id="sm-invites-popup-title"></h3>
</header>
<div class="modal-body form-horizontal" id="sm-invites-popup-body">
</div>
<footer>
</footer>

View file

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2016 RainLoop Team
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,23 @@
<?php
class SendSaveInPlugin extends \RainLoop\Plugins\AbstractPlugin
{
// use \MailSo\Log\Inherit;
const
NAME = 'Send Save In',
AUTHOR = 'SnappyMail',
URL = 'https://snappymail.eu/',
VERSION = '0.1',
RELEASE = '2024-10-08',
REQUIRED = '2.38.1',
CATEGORY = 'General',
LICENSE = 'MIT',
DESCRIPTION = 'When composing a message, select the save folder';
public function Init() : void
{
// $this->UseLangs(true); // start use langs folder
$this->addJs('savein.js');
}
}

View file

@ -0,0 +1,75 @@
(rl => {
const templateId = 'PopupsCompose',
folderListOptionsBuilder = () => {
const
aResult = [{
id: '',
name: '',
system: false,
disabled: false
}],
sDeepPrefix = '\u00A0\u00A0\u00A0',
showUnsubscribed = true/*!SettingsUserStore.hideUnsubscribed()*/,
foldersWalk = folders => {
folders.forEach(oItem => {
if (showUnsubscribed || oItem.hasSubscriptions() || !oItem.exists) {
aResult.push({
id: oItem.fullName,
name: sDeepPrefix.repeat(oItem.deep) + oItem.detailedName(),
system: false,
disabled: !oItem.selectable()
});
}
if (oItem.subFolders.length) {
foldersWalk(oItem.subFolders());
}
});
};
// FolderUserStore.folderList()
foldersWalk(rl.app.folderList() || []);
return aResult;
};
let oldSentFolderFn;
addEventListener('rl-view-model.create', e => {
if (templateId === e.detail.viewModelTemplateID) {
const view = e.detail; // ComposePopupView
view.sentFolderValue = ko.observable('');
view.sentFolderSelectList = ko.computed(folderListOptionsBuilder, {'pure':true});
view.defaultOptionsAfterRender = (domItem, item) =>
item && undefined !== item.disabled && domItem?.classList.toggle('disabled', domItem.disabled = item.disabled);
oldSentFolderFn = view.sentFolder.bind(view);
view.sentFolder = () => view.sentFolderValue() || oldSentFolderFn();
document.getElementById(templateId).content.querySelector('.b-header tbody').append(Element.fromHTML(`
<tr>
<td>Store in</td>
<td>
<select class="span3" data-bind="options: sentFolderSelectList, value: sentFolderValue,
optionsText: 'name', optionsValue: 'id', optionsAfterRender: defaultOptionsAfterRender"></select>
(When send, store a copy of the message in the selected folder)
</td>
</tr>`));
view.currentIdentity.subscribe(()=>{
view.sentFolderValue(oldSentFolderFn());
});
}
});
addEventListener('rl-vm-visible', e => {
if (templateId === e.detail.viewModelTemplateID) {
const view = e.detail; // ComposePopupView
view.sentFolderValue(oldSentFolderFn());
}
});
})(window.rl);

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After youve used a backup code to sign in, it will become inactive.\n"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After youve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not configured"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After youve used a backup code to sign in, it will become inactive.\n"
TWO_FACTOR_SECRET_DESC = "Import this info into your Google Authenticator client (or other TOTP client) using the provided QR code below or by entering the code manually."
TWO_FACTOR_BACKUP_CODES_DESC = "If you can't receive codes via Google Authenticator (or other TOTP client), you can use backup codes to sign in. After youve used a backup code to sign in, it will become inactive."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
BUTTON_HIDE_SECRET = "Titok elrejtése"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs beállítva"
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik.\n"
TWO_FACTOR_SECRET_DESC = "Importáld ezt az infót a Google Authenticator kliensedbe (vagy más TOTP kliensbe) az alábbi QR kód használatával vagy a kód manuális megadatásával."
TWO_FACTOR_BACKUP_CODES_DESC = "Ha nem kapod meg a kódokat a Google Authenticator kliensből (vagy más TOTP kliensből), akkor a bejelentkezéshez használhatod a biztonsági kódot. A biztonsági kód használata után inaktívvá válik."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást."
TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
LABEL_CODE = "Kód"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non configurato"
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente.\n"
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo.\n"
TWO_FACTOR_SECRET_DESC = "Importa queste informazioni nella tua app Google Authenticator (o un altro programma per l'autenticazione a due fattori) utilizzando il codice QR fornito qui sotto o inserendo il codice manualmente."
TWO_FACTOR_BACKUP_CODES_DESC = "Se non riesci a ricevere i codici tramite Google Authenticator (o un altro programma per l'autenticazione a due fattori), puoi utilizzare i codici di backup per accedere. Dopo che hai utilizzato un codice di backup per accedere, diventerà inattivo."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
TITLE_TEST_CODE = "Test di verifica a 2 fattori"
LABEL_CODE = "Codice"

View file

@ -0,0 +1,20 @@
[PLUGIN_2FA]
LEGEND_TWO_FACTOR_AUTH = "2-етапна перевірка (TOTP)"
LABEL_ENABLE_TWO_FACTOR = "Увімкнути 2-етапну перевірку"
LABEL_TWO_FACTOR_USER = "Ім'я користувача"
LABEL_TWO_FACTOR_STATUS = "Статус"
LABEL_TWO_FACTOR_SECRET = "Ключ"
LABEL_TWO_FACTOR_BACKUP_CODES = "Резервні коди"
BUTTON_CREATE = "Create a secret"
BUTTON_ACTIVATE = "Activate"
LINK_TEST = "Тестувати"
BUTTON_SHOW_SECRET = "Показати ключ"
BUTTON_HIDE_SECRET = "Приховати ключ"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Налаштовано"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Не налаштовано"
TWO_FACTOR_SECRET_DESC = "Імпортуйте цю інформацію у свій клієнт Google Authenticator (або інший клієнт TOTP), використовуючи наданий нижче QR-код або ввівши код вручну"
TWO_FACTOR_BACKUP_CODES_DESC = "Якщо ви не можете отримати код через Google Authenticator (або інший клієнт TOTP), ви можете скористатися резервними кодами для входу. Після того, як ви використаєте резервний код для входу, він стане неактивним. Закреслюйте його"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Ви не можете змінити це налаштування перед тестуванням."
TITLE_TEST_CODE = "Тест 2-етапної перевірки"
LABEL_CODE = "Код"
LABEL_TWO_FACTOR_CODE = "Код підтвердження"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "显示密钥"
BUTTON_HIDE_SECRET = "隐藏密钥"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
TITLE_TEST_CODE = "两步验证测试"
LABEL_CODE = "代码"