mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-09-01 21:49:21 +03:00
Merge branch 'master' into dev/sync-nextcloud-addressbook
This commit is contained in:
commit
721a849254
122 changed files with 1816 additions and 1677 deletions
13
plugins/change-password/langs/uk.ini
Normal file
13
plugins/change-password/langs/uk.ini
Normal 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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
20
plugins/send-save-in/LICENSE
Normal file
20
plugins/send-save-in/LICENSE
Normal 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.
|
||||
23
plugins/send-save-in/index.php
Normal file
23
plugins/send-save-in/index.php
Normal 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');
|
||||
}
|
||||
}
|
||||
75
plugins/send-save-in/savein.js
Normal file
75
plugins/send-save-in/savein.js
Normal 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);
|
||||
|
|
@ -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 you’ve 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 you’ve 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"
|
||||
|
|
|
|||
|
|
@ -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 you’ve 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 you’ve 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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
20
plugins/two-factor-auth/langs/uk.ini
Normal file
20
plugins/two-factor-auth/langs/uk.ini
Normal 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 = "Код підтвердження"
|
||||
|
|
@ -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 = "代码"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue