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

@ -1,3 +1,60 @@
## 2.38.2 2024-10-09
### Fixed
- error '$index is not defined' in Settings > Accounts
[#1797](https://github.com/the-djmaze/snappymail/issues/1797)
## 2.38.1 2024-10-08
### Added
- Admin - Extensions search filter
- Options to unset current font family and size
[#1726](https://github.com/the-djmaze/snappymail/issues/1726)
- Option to allow insecure cryptkey
[#1746](https://github.com/the-djmaze/snappymail/issues/1746)
- Save vCard FN property
[#1761](https://github.com/the-djmaze/snappymail/issues/1761)
- Docker Nginx should listen on IPv6 addresses in addition to IPv4
[#1770](https://github.com/the-djmaze/snappymail/issues/1770)
- Full-screen Message View on Double-Click
[#1787](https://github.com/the-djmaze/snappymail/issues/1787)
### Changed
- Use the custom Squire as submodule vendors/squire2
- Disallow noembed and noframes HTML elements
- Keep 1 space between HTML elements in compressed templates
- Squire: sanitizeToDOMFragment now uses cleanHTML()
- Squire: improved handling of BR elements
[#1389](https://github.com/the-djmaze/snappymail/issues/1389)
- Nextcloud border-radius by @gnilebein
[#1790](https://github.com/the-djmaze/snappymail/pull/1790)
- Compose window sentFolder handling for
[#1793](https://github.com/the-djmaze/snappymail/pull/1793)
- Update Portuguese by @ner00
- Update Polish by @tinola
- Update French by @hguilbert
- Update Portuguese (Brazil) by @mstolf
### Fixed
- Composer dialog scroll got broken in v2.28
- Composer dialog "from" triangle button wrong position due to font changes
- Admin - Config `search` should be ko.observable() not ko.observableArray()
- Squire: paste images
[#1389](https://github.com/the-djmaze/snappymail/issues/1389)
- Domain whitelist failures on login
[#1706](https://github.com/the-djmaze/snappymail/issues/1706)
- Sieve parse errors
- Sieve support `index` was not optional for
[#1709](https://github.com/the-djmaze/snappymail/issues/1709)
- Pagination problem for large mailbox after 2.36.1
[#1716](https://github.com/the-djmaze/snappymail/issues/1716)
- Undefined constant "LOG_ERROR"
[#1754](https://github.com/the-djmaze/snappymail/issues/1754)
- Search Filter Capital "B" not working
[#1780](https://github.com/the-djmaze/snappymail/issues/1780)
- PHP 8.4: Implicitly nullable parameter declarations deprecated
## 2.38.0 2024-09-16 ## 2.38.0 2024-09-16
### Added ### Added
@ -247,7 +304,7 @@
- CSS set min-width for .attachmentParent and .flagParent to line them up - CSS set min-width for .attachmentParent and .flagParent to line them up
- cPanel use extension login-cpanel instead of login-remote - cPanel use extension login-cpanel instead of login-remote
- Improved login credentials handling - Improved login credentials handling
- Speedup Knockout a bit - Speedup Knockout a bit and removed `with($context)` scope
- Update Belarusian by @spoooyders - Update Belarusian by @spoooyders
- Update Chinese by @mayswind - Update Chinese by @mayswind
- Update French by @hguilbert - Update French by @hguilbert

View file

@ -66,7 +66,7 @@ This fork of RainLoop has the following changes:
* CRLF => LF line endings * CRLF => LF line endings
* Embed boot.js and boot.css into index.html * Embed boot.js and boot.css into index.html
* Removal of old JavaScript code (things are native these days) * Removal of old JavaScript code (things are native these days)
* Added modified [Squire](https://github.com/neilj/Squire) HTML editor as replacement for CKEditor * Added modified [Squire](https://github.com/the-djmaze/Squire/tree/snappymail) HTML editor as replacement for CKEditor
* Updated [Sabre/VObject](https://github.com/sabre-io/vobject) * Updated [Sabre/VObject](https://github.com/sabre-io/vobject)
* Split Admin specific JavaScript code from User code * Split Admin specific JavaScript code from User code
* Split Sieve specific JavaScript code from User code * Split Sieve specific JavaScript code from User code
@ -140,28 +140,28 @@ RainLoop 1.17 vs SnappyMail
|js/* |RainLoop |Snappy | |js/* |RainLoop |Snappy |
|--------------- |--------: |--------: | |--------------- |--------: |--------: |
|admin.js |2.170.153 | 84.203 | |admin.js |2.170.153 | 84.925 |
|app.js |4.207.787 | 446.887 | |app.js |4.207.787 | 447.263 |
|boot.js | 868.735 | 4.343 | |boot.js | 868.735 | 4.343 |
|libs.js | 658.812 | 236.059 | |libs.js | 658.812 | 233.728 |
|sieve.js | 0 | 84.571 | |sieve.js | 0 | 91.418 |
|polyfills.js | 334.608 | 0 | |polyfills.js | 334.608 | 0 |
|serviceworker.js | 0 | 285 | |serviceworker.js | 0 | 285 |
|TOTAL |8.240.095 | 856.348 | |TOTAL |8.240.095 | 861.962 |
|js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli | |js/min/* |RainLoop |Snappy |RL gzip |SM gzip |RL brotli |SM brotli |
|--------------- |--------: |--------: |------: |------: |--------: |--------: | |--------------- |--------: |--------: |------: |------: |--------: |--------: |
|admin.min.js | 256.831 | 41.256 | 73.606 | 13.940 | 60.877 | 12.493 | |admin.min.js | 256.831 | 41.719 | 73.606 | 14.022 | 60.877 | 12.567 |
|app.min.js | 515.367 | 201.917 |139.456 | 68.450 |110.485 | 58.321 | |app.min.js | 515.367 | 202.101 |139.456 | 68.505 |110.485 | 58.481 |
|boot.min.js | 84.659 | 2.231 | 26.998 | 1.271 | 23.643 | 1.067 | |boot.min.js | 84.659 | 2.231 | 26.998 | 1.271 | 23.643 | 1.067 |
|libs.min.js | 584.772 | 111.742 |180.901 | 39.882 |155.182 | 35.584 | |libs.min.js | 584.772 | 110.646 |180.901 | 39.518 |155.182 | 35.207 |
|sieve.min.js | 0 | 41.288 | 0 | 10.327 | 0 | 9.318 | |sieve.min.js | 0 | 45.504 | 0 | 11.131 | 0 | 9.917 |
|polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 | |polyfills.min.js | 32.837 | 0 | 11.406 | 0 | 10.175 | 0 |
|TOTAL user |1.217.635 | 315.890 |358.761 |109.603 |299.485 | 94.972 | |TOTAL user |1.217.635 | 314.978 |358.761 |109.294 |299.485 | 94.755 |
|TOTAL user+sieve |1.217.635 | 357.178 |358.761 |119.930 |299.485 |104.290 | |TOTAL user+sieve |1.217.635 | 360.482 |358.761 |120.425 |299.485 |104.672 |
|TOTAL admin | 959.099 | 155.229 |292.911 | 55.093 |249.877 | 49.144 | |TOTAL admin | 959.099 | 154.596 |292.911 | 54.811 |249.877 | 48.841 |
For a user it is around 67% smaller and faster than traditional RainLoop. For a user it is around 66% smaller and faster than traditional RainLoop.
### CSS changes ### CSS changes
@ -188,12 +188,12 @@ For a user it is around 67% smaller and faster than traditional RainLoop.
|css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli | |css/* |RainLoop |Snappy |RL gzip |SM gzip |SM brotli |
|------------ |-------: |------: |------: |------: |--------: | |------------ |-------: |------: |------: |------: |--------: |
|app.css | 340.331 | 84.850 | 46.946 | 17.710 | 15.161 | |app.css | 340.331 | 85.073 | 46.946 | 17.792 | 15.210 |
|app.min.css | 274.947 | 68.192 | 39.647 | 15.602 | 13.626 | |app.min.css | 274.947 | 68.272 | 39.647 | 15.615 | 13.636 |
|boot.css | | 1.326 | | 664 | 545 | |boot.css | | 1.326 | | 664 | 545 |
|boot.min.css | | 1.071 | | 590 | 474 | |boot.min.css | | 1.071 | | 590 | 474 |
|admin.css | | 30.761 | | 7.038 | 6.118 | |admin.css | | 30.880 | | 7.045 | 6.127 |
|admin.min.css | | 24.857 | | 6.360 | 5.598 | |admin.min.css | | 24.959 | | 6.368 | 5.615 |
### PGP ### PGP
RainLoop uses the old OpenPGP.js v2 RainLoop uses the old OpenPGP.js v2

View file

@ -23,7 +23,7 @@ const
// Structural Elements: // Structural Elements:
'blockquote','br','div','figcaption','figure','h1','h2','h3','h4','h5','h6','hgroup','hr','p','wbr', 'blockquote','br','div','figcaption','figure','h1','h2','h3','h4','h5','h6','hgroup','hr','p','wbr',
'article','aside','header','footer','main','section', 'article','aside','header','footer','main','section',
'details','summary', 'details','summary','nav',
// List Elements // List Elements
'dd','dl','dt','li','ol','ul', 'dd','dl','dt','li','ol','ul',
// Text Formatting Elements // Text Formatting Elements
@ -239,6 +239,8 @@ export const
linkedData: [] linkedData: []
}, },
isMsg = !!msgId,
findAttachmentByCid = cId => oAttachments.findByCid(cId), findAttachmentByCid = cId => oAttachments.findByCid(cId),
findLocationByCid = cId => { findLocationByCid = cId => {
const attachment = findAttachmentByCid(cId); const attachment = findAttachmentByCid(cId);
@ -282,6 +284,8 @@ export const
'abbr', 'scope', 'abbr', 'scope',
// td // td
'colspan', 'rowspan', 'headers' 'colspan', 'rowspan', 'headers'
// others
//'class', 'id', 'target'
]; ];
if (SettingsUserStore.allowStyles()) { if (SettingsUserStore.allowStyles()) {
@ -332,7 +336,7 @@ export const
tmpl.content.querySelectorAll(keepTagContent).forEach(oElement => replaceWithChildren(oElement)); tmpl.content.querySelectorAll(keepTagContent).forEach(oElement => replaceWithChildren(oElement));
tmpl.content.querySelectorAll( tmpl.content.querySelectorAll(
':not('+allowedTags+')' ':not('+allowedTags+'),a:empty,span:empty'
+ (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '') + (0 < bqLevel ? ',' + (new Array(1 + bqLevel).fill('blockquote').join(' ')) : '')
).forEach(oElement => oElement.remove()); ).forEach(oElement => oElement.remove());
/* // Is this slower or faster? /* // Is this slower or faster?
@ -481,71 +485,73 @@ export const
*/ */
let skipStyle = false; let skipStyle = false;
value = delAttribute('src'); if (isMsg) {
if (value) { value = isMsg && delAttribute('src');
if ('IMG' === name) { if (value) {
oElement.loading = 'lazy'; if ('IMG' === name) {
let attachment; oElement.loading = 'lazy';
if (value.startsWith('cid:')) let attachment;
{ if (value.startsWith('cid:'))
value = value.slice(4); {
setAttribute('data-x-src-cid', value); value = value.slice(4);
attachment = findAttachmentByCid(value); setAttribute('data-x-src-cid', value);
if (attachment?.download) { attachment = findAttachmentByCid(value);
oElement.src = attachment.linkPreview(); if (attachment?.download) {
oElement.title += ' ('+attachment.fileName+')'; oElement.src = attachment.linkPreview();
attachment.isInline(true); oElement.title += ' ('+attachment.fileName+')';
attachment.isLinked(true); attachment.isInline(true);
attachment.isLinked(true);
}
} }
} else if ((attachment = findLocationByCid(value)))
else if ((attachment = findLocationByCid(value))) {
{ if (attachment.download) {
if (attachment.download) { oElement.src = attachment.linkPreview();
oElement.src = attachment.linkPreview(); attachment.isLinked(true);
attachment.isLinked(true); }
} }
} else if (detectHiddenImages
else if (detectHiddenImages && ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in'
&& ((oStyle.maxHeight && 3 > pInt(oStyle.maxHeight)) // TODO: issue with 'in' || (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in'
|| (oStyle.maxWidth && 3 > pInt(oStyle.maxWidth)) // TODO: issue with 'in' || (oStyle.width && 2 > pInt(oStyle.width))
|| (oStyle.width && 2 > pInt(oStyle.width)) || [
|| [ 'email.microsoftemail.com/open',
'email.microsoftemail.com/open', 'github.com/notifications/beacon/',
'github.com/notifications/beacon/', '/track/open', // mandrillapp.com list-manage.com
'/track/open', // mandrillapp.com list-manage.com 'google-analytics.com'
'google-analytics.com' ].filter(uri => value.toLowerCase().includes(uri)).length
].filter(uri => value.toLowerCase().includes(uri)).length )) {
)) { skipStyle = true;
skipStyle = true; oStyle.display = 'none';
oStyle.display = 'none'; // setAttribute('style', 'display:none');
// setAttribute('style', 'display:none'); setAttribute('data-x-src-hidden', value);
setAttribute('data-x-src-hidden', value); // result.tracking = true;
// result.tracking = true; }
} else if (httpre.test(value))
else if (httpre.test(value)) {
{ let src = stripTracking(value);
let src = stripTracking(value); if (src != value) {
if (src != value) { result.tracking = true;
result.tracking = true; setAttribute('data-x-src-tracking', value);
setAttribute('data-x-src-tracking', value); }
setAttribute('data-x-src', src);
result.hasExternals = true;
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
}
else if (value.startsWith('data:image/'))
{
oElement.src = value;
}
else
{
setAttribute('data-x-src-broken', value);
} }
setAttribute('data-x-src', src);
result.hasExternals = true;
oElement.alt || (oElement.alt = src.replace(/^.+\/([^/?]+).*$/, '$1').slice(-20));
}
else if (value.startsWith('data:image/'))
{
oElement.src = value;
} }
else else
{ {
setAttribute('data-x-src-broken', value); setAttribute('data-x-src-broken', value);
} }
} }
else
{
setAttribute('data-x-src-broken', value);
}
} }
if (hasAttribute('background')) { if (hasAttribute('background')) {
@ -631,7 +637,7 @@ export const
} }
}); });
blockquoteSwitcher(); isMsg && blockquoteSwitcher();
// return tmpl.content.firstChild; // return tmpl.content.firstChild;
result.html = tmpl.innerHTML.trim(); result.html = tmpl.innerHTML.trim();
@ -819,6 +825,7 @@ export const
}; };
rl.Utils = { rl.Utils = {
cleanHtml: cleanHtml,
htmlToPlain: htmlToPlain, htmlToPlain: htmlToPlain,
plainToHtml: plainToHtml, plainToHtml: plainToHtml,
htmlToMarkdown: htmlToMarkdown htmlToMarkdown: htmlToMarkdown

View file

@ -3,10 +3,6 @@
(doc => { (doc => {
const const
removeElements = 'HEAD,LINK,META,NOSCRIPT,SCRIPT,TEMPLATE,TITLE',
allowedElements = 'A,B,BLOCKQUOTE,BR,DIV,FONT,H1,H2,H3,H4,H5,H6,HR,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(','),
i18n = (str, def) => rl.i18n(str) || def, i18n = (str, def) => rl.i18n(str) || def,
ctrlKey = shortcuts.getMetaKey() + ' + ', ctrlKey = shortcuts.getMetaKey() + ' + ',
@ -21,38 +17,17 @@ const
forEachObjectValue = (obj, fn) => Object.values(obj).forEach(fn), forEachObjectValue = (obj, fn) => Object.values(obj).forEach(fn),
getFragmentOfChildren = parent => {
let frag = doc.createDocumentFragment();
frag.append(...parent.childNodes);
return frag;
},
SquireDefaultConfig = { SquireDefaultConfig = {
/* /*
addLinks: true // allow_smart_html_links addLinks: true // allow_smart_html_links
*/ */
sanitizeToDOMFragment: (html, isPaste/*, squire*/) => { sanitizeToDOMFragment: (html, isPaste/*, squire*/) => {
tpl.innerHTML = (html||'') html = (html||'')
.replace(/<\/?(BODY|HTML)[^>]*>/gi,'') .replace(/<\/?(BODY|HTML)[^>]*>/gi,'')
.replace(/<!--[^>]+-->/g,'') .replace(/<!--[^>]+-->/g,'')
.replace(/<span[^>]*>\s*<\/span>/gi,'') .replace(/<span[^>]*>\s*<\/span>/gi,'')
.trim(); .trim();
tpl.querySelectorAll('a:empty,span:empty').forEach(el => el.remove()); tpl.innerHTML = isPaste ? rl.Utils.cleanHtml(html).html : html;
if (isPaste) {
tpl.querySelectorAll(removeElements).forEach(el => el.remove());
tpl.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 tpl.content; return tpl.content;
} }
}; };
@ -411,6 +386,44 @@ class SquireUI
changes.redo.input.disabled = !e.detail.canRedo; changes.redo.input.disabled = !e.detail.canRedo;
}); });
squire.addEventListener('pasteImage', e => {
const items = e.detail.clipboardData.items;
let l = items.length;
while (l--) {
const item = items[l];
if (/^image\/(png|jpeg|webp)/.test(item.type)) {
let reader = new FileReader();
reader.onload = event => {
let img = createElement("img"),
canvas = createElement("canvas"),
ctx = canvas.getContext('2d');
img.onload = ()=>{
ctx.drawImage(img, 0, 0);
let width = img.width, height = img.height;
if (width > height) {
// Landscape
if (width > 1024) {
height = height * 1024 / width;
width = 1024;
}
} else if (height > 1024) {
// Portrait
width = width * 1024 / height;
height = 1024;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
squire.insertHTML('<img alt="" style="width:100%;max-width:'+width+'px" src="'+canvas.toDataURL()+'">', true);
};
img.src = event.target.result;
}
reader.readAsDataURL(item.getAsFile());
break;
}
}
});
actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex; actions.font.fontSize.input.selectedIndex = actions.font.fontSize.defaultValueIndex;
// squire.addEventListener('focus', () => shortcuts.off()); // squire.addEventListener('focus', () => shortcuts.off());

View file

@ -44,6 +44,51 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.desktopNotifications = NotificationUserStore.enabled; this.desktopNotifications = NotificationUserStore.enabled;
this.isDesktopNotificationAllowed = NotificationUserStore.allowed; this.isDesktopNotificationAllowed = NotificationUserStore.allowed;
const i18nSort = s => i18n('MESSAGE_LIST/SORT_'+s);
this.sortSupported = FolderUserStore.hasCapability('SORT');
this.sortOptions = [
{
value: '',
text: i18nSort('DATE_DESC')
},
{
value: 'DATE',
text: i18nSort('DATE_ASC')
},
{
value: 'FROM',
text: i18nSort('FROM_ASC')
},
{
value: 'REVERSE FROM',
text: i18nSort('FROM_DESC')
},
{
value: 'REVERSE SIZE',
text: i18nSort('SIZE_DESC')
},
{
value: 'SIZE',
text: i18nSort('SIZE_ASC')
},
{
value: 'SUBJECT',
text: i18nSort('SUBJECT_ASC')
},
{
value: 'REVERSE SUBJECT',
text: i18nSort('SUBJECT_DESC')
},
{
value: 'REVERSE ARRIVAL',
text: i18nSort('ARRIVAL_DESC')
},
{
value: 'ARRIVAL',
text: i18nSort('ARRIVAL_ASC')
}
];
this.threadsAllowed = AppUserStore.threadsAllowed; this.threadsAllowed = AppUserStore.threadsAllowed;
// 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT' // 'THREAD=REFS', 'THREAD=REFERENCES', 'THREAD=ORDEREDSUBJECT'
this.threadAlgorithms = ko.observableArray(); this.threadAlgorithms = ko.observableArray();
@ -51,9 +96,8 @@ export class UserSettingsGeneral extends AbstractViewSettings {
capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7)) capa.startsWith('THREAD=') && this.threadAlgorithms.push(capa.slice(7))
); );
this.threadAlgorithms.sort((a, b) => a.length - b.length); this.threadAlgorithms.sort((a, b) => a.length - b.length);
this.threadAlgorithm = SettingsUserStore.threadAlgorithm;
['useThreads', 'threadAlgorithm', ['defaultSort', 'useThreads', 'threadAlgorithm',
// These use addSetting() // These use addSetting()
'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval', 'layout', 'messageReadDelay', 'messagesPerPage', 'checkMailInterval',
'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel', 'editorDefaultType', 'editorWysiwyg', 'msgDefaultAction', 'maxBlockquotesLevel',
@ -117,6 +161,7 @@ export class UserSettingsGeneral extends AbstractViewSettings {
this.addSetting('CheckMailInterval'); this.addSetting('CheckMailInterval');
this.addSetting('Layout'); this.addSetting('Layout');
this.addSetting('MaxBlockquotesLevel'); this.addSetting('MaxBlockquotesLevel');
this.addSetting('defaultSort');
this.addSettings([ this.addSettings([
'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt', 'requestReadReceipt', 'requestDsn', 'requireTLS', 'pgpSign', 'pgpEncrypt',

View file

@ -66,7 +66,7 @@ export class FilterPopupView extends rl.pluginPopupView {
}); });
this.defaultOptionsAfterRender = defaultOptionsAfterRender; this.defaultOptionsAfterRender = defaultOptionsAfterRender;
this.folderSelectList = koComputable(() => folderListOptionsBuilder()); this.folderSelectList = koComputable(folderListOptionsBuilder);
this.selectedFolderValue.subscribe(() => this.filter().actionValueError(false)); this.selectedFolderValue.subscribe(() => this.filter().actionValueError(false));

View file

@ -7,6 +7,7 @@ import { forEachObjectEntry } from 'Common/Utils';
import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache'; import { getFolderInboxName, getFolderFromCacheList } from 'Common/Cache';
import { Settings, SettingsCapa } from 'Common/Globals'; import { Settings, SettingsCapa } from 'Common/Globals';
//import Remote from 'Remote/User/Fetch'; // Circular dependency //import Remote from 'Remote/User/Fetch'; // Circular dependency
import { SettingsUserStore } from 'Stores/User/Settings';
export const export const
@ -59,7 +60,7 @@ FolderUserStore = new class {
*/ */
displaySpecSetting: false, displaySpecSetting: false,
sortMode: '', sortMode: SettingsUserStore.defaultSort(),
quotaLimit: 0, quotaLimit: 0,
quotaUsage: 0, quotaUsage: 0,

View file

@ -29,6 +29,7 @@ export const SettingsUserStore = new class {
showNextMessage: 0, showNextMessage: 0,
allowDraftAutosave: 1, allowDraftAutosave: 1,
useThreads: 0, useThreads: 0,
defaultSort: '',
threadAlgorithm: '', threadAlgorithm: '',
replySameFolder: 0, replySameFolder: 0,
hideUnsubscribed: 0, hideUnsubscribed: 0,
@ -103,6 +104,7 @@ export const SettingsUserStore = new class {
'showNextMessage', 'showNextMessage',
'AllowDraftAutosave', 'AllowDraftAutosave',
'useThreads', 'useThreads',
'defaultSort',
'threadAlgorithm', 'threadAlgorithm',
'ReplySameFolder', 'ReplySameFolder',
'HideUnsubscribed', 'HideUnsubscribed',

View file

@ -22,12 +22,18 @@
.tabs { .tabs {
height: 100%; height: 100%;
max-height: 100%;
overflow: auto;
} }
.tabs > label > * + * { .tabs > label > * + * {
margin-left: 0.5em; margin-left: 0.5em;
} }
.tabs .tab-content {
overflow: auto;
}
.textAreaParent, .attachmentAreaParent { .textAreaParent, .attachmentAreaParent {
height: 100%; height: 100%;
min-height: 200px; min-height: 200px;

View file

@ -45,4 +45,8 @@
content: ')'; content: ')';
} }
.identities-list tr + tr .identity-default {
display: none;
}
} }

View file

@ -56,6 +56,12 @@
.squire-wysiwyg { .squire-wysiwyg {
font-family: var(--fontSans); font-family: var(--fontSans);
/* Prevent collapse of empty elements so that the caret can be placed in there
*:empty::before {
content: "\200B";
}
*/
ul { ul {
padding-left: 40px; padding-left: 40px;
li { li {

View file

@ -457,10 +457,21 @@ export class ComposePopupView extends AbstractViewPopup {
this.from(IdentityUserStore()[0].formattedName()); this.from(IdentityUserStore()[0].formattedName());
} }
sendCommand() { sentFolder()
const identity = this.currentIdentity(); {
let sSentFolder = identity?.sentFolder?.() || FolderUserStore.sentFolder(); let sSentFolder = this.currentIdentity()?.sentFolder?.() || FolderUserStore.sentFolder();
if (SettingsUserStore.replySameFolder()) {
if (
3 === arrayLength(this.aDraftInfo) &&
this.aDraftInfo[2]?.length
) {
sSentFolder = this.aDraftInfo[2];
}
}
return UNUSED_OPTION_VALUE === sSentFolder ? null : sSentFolder;
}
sendCommand() {
this.attachmentsInProcessError(false); this.attachmentsInProcessError(false);
this.attachmentsInErrorError(false); this.attachmentsInErrorError(false);
this.emptyToError(false); this.emptyToError(false);
@ -478,16 +489,8 @@ export class ComposePopupView extends AbstractViewPopup {
} }
if (!this.emptyToError() && !this.attachmentsInErrorError() && !this.attachmentsInProcessError()) { if (!this.emptyToError() && !this.attachmentsInErrorError() && !this.attachmentsInProcessError()) {
if (SettingsUserStore.replySameFolder()) { const sSentFolder = this.sentFolder();
if ( if ('' === sSentFolder) {
3 === arrayLength(this.aDraftInfo) &&
this.aDraftInfo[2]?.length
) {
sSentFolder = this.aDraftInfo[2];
}
}
if (!sSentFolder) {
showScreenPopup(FolderSystemPopupView, [FolderType.Sent]); showScreenPopup(FolderSystemPopupView, [FolderType.Sent]);
} else { } else {
const sendError = e => { const sendError = e => {
@ -507,8 +510,6 @@ export class ComposePopupView extends AbstractViewPopup {
this.sendError(false); this.sendError(false);
this.sending(true); this.sending(true);
sSentFolder = UNUSED_OPTION_VALUE === sSentFolder ? '' : sSentFolder;
const sendMessage = params => { const sendMessage = params => {
Remote.request('SendMessage', Remote.request('SendMessage',
(iError, data) => { (iError, data) => {
@ -538,7 +539,7 @@ export class ComposePopupView extends AbstractViewPopup {
this.sendError(true); this.sendError(true);
sendFailed(iError, data); sendFailed(iError, data);
// Remove remembered passphrase as it could be wrong // Remove remembered passphrase as it could be wrong
let key = ('S/MIME' === params.sign) ? identity : null; let key = ('S/MIME' === params.sign) ? this.currentIdentity() : null;
params.signFingerprint params.signFingerprint
&& this.signOptions.forEach(option => ('GnuPG' === option[0]) && (key = option[1])); && this.signOptions.forEach(option => ('GnuPG' === option[0]) && (key = option[1]));
key && Passphrases.delete(key); key && Passphrases.delete(key);
@ -560,10 +561,9 @@ export class ComposePopupView extends AbstractViewPopup {
this.close(); this.close();
} }
setFolderETag(this.draftsFolder(), ''); setFolderETag(this.draftsFolder(), '');
setFolderETag(sSentFolder, ''); setFolderETag(params.saveFolder, '');
if (3 === arrayLength(this.aDraftInfo)) { if (3 === arrayLength(this.aDraftInfo)) {
const folder = this.aDraftInfo[2]; setFolderETag(this.aDraftInfo[2], '');
setFolderETag(folder, '');
} }
reloadDraftFolder(); reloadDraftFolder();
}, },

View file

@ -213,15 +213,19 @@ export class MailMessageList extends AbstractViewRight {
sortText: () => { sortText: () => {
let mode = FolderUserStore.sortMode(), let mode = FolderUserStore.sortMode(),
desc = '' === mode || mode.includes('REVERSE'); has = w => mode.includes(w),
desc = '' === mode || has('REVERSE');
mode = mode.split(/\s+/); mode = mode.split(/\s+/);
if (mode.includes('FROM')) { if (has('FROM')) {
return '@' + (desc ? '⬆' : '⬇'); return '@' + (desc ? '⬆' : '⬇');
} }
if (mode.includes('SUBJECT')) { if (has('SUBJECT')) {
return '𝐒' + (desc ? '⬆' : '⬇'); return '𝐒' + (desc ? '⬆' : '⬇');
} }
return (mode.includes('SIZE') ? '✉' : '📅') + (desc ? '⬇' : '⬆'); if (has('SIZE')) {
return '✉' + (desc ? '⬇' : '⬆');
}
return (has('ARRIVAL') ? '📨' : '📅') + (desc ? '⬇' : '⬆');
}, },
downloadAsZipAllowed: () => this.attachmentsActions.includes('zip') downloadAsZipAllowed: () => this.attachmentsActions.includes('zip')
@ -629,8 +633,10 @@ export class MailMessageList extends AbstractViewRight {
} }
}, },
dblclick: event => { dblclick: event => {
let el = eqs(event, '.messageListItem'); let msg = ko.dataFor(eqs(event, '.messageListItem'));
el && this.gotoThread(ko.dataFor(el)); if (msg) {
msg.threadsLen() ? this.gotoThread(msg) : toggleFullscreen();
}
} }
}); });

View file

@ -20,7 +20,8 @@ import {
fireEvent, fireEvent,
stopEvent, stopEvent,
addShortcut, addShortcut,
registerShortcut registerShortcut,
formFieldFocused
} from 'Common/Globals'; } from 'Common/Globals';
import { arrayLength } from 'Common/Utils'; import { arrayLength } from 'Common/Utils';
@ -467,8 +468,10 @@ export class MailMessageView extends AbstractViewRight {
}); });
addShortcut('b', 'shift', [ScopeMessageList, ScopeMessageView], () => { addShortcut('b', 'shift', [ScopeMessageList, ScopeMessageView], () => {
currentMessage()?.swapColors?.(); if (!formFieldFocused()) {
return false; currentMessage()?.swapColors?.();
return false;
}
}); });
addShortcut('arrowup,arrowleft', 'meta', [ScopeMessageList, ScopeMessageView], () => { addShortcut('arrowup,arrowleft', 'meta', [ScopeMessageList, ScopeMessageView], () => {

View file

@ -1,4 +1,4 @@
This app packages SnappyMail <upstream>2.38.0</upstream>. This app packages SnappyMail <upstream>2.38.2</upstream>.
SnappyMail is a simple, modern, lightweight & fast web-based email client. SnappyMail is a simple, modern, lightweight & fast web-based email client.

View file

@ -4,7 +4,7 @@ RUN mkdir -p /app/code
WORKDIR /app/code WORKDIR /app/code
# If you change the extraction below, be sure to test on scaleway # If you change the extraction below, be sure to test on scaleway
VERSION=2.38.0 VERSION=2.38.2
RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \ RUN wget https://github.com/the-djmaze/snappymail/releases/download/v${VERSION}/snappymail-${VERSION}.zip -O /tmp/snappymail.zip && \
unzip /tmp/snappymail.zip -d /app/code && \ unzip /tmp/snappymail.zip -d /app/code && \
rm /tmp/snappymail.zip && \ rm /tmp/snappymail.zip && \

View file

@ -3,7 +3,7 @@
<id>snappymail</id> <id>snappymail</id>
<name>SnappyMail</name> <name>SnappyMail</name>
<summary>SnappyMail Webmail</summary> <summary>SnappyMail Webmail</summary>
<version>2.38.0</version> <version>2.38.2</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author> <author>SnappyMail, RainLoop Team, Nextgen-Networks, Tab Fitts, Nathan Kinkade, Pierre-Alain Bandinelli</author>
<description><![CDATA[**Lightweight & fast email client.** <description><![CDATA[**Lightweight & fast email client.**

View file

@ -20,7 +20,7 @@ return "SnappyMail Webmail is a browser-based multilingual IMAP client with an a
# script_snappymail_versions() # script_snappymail_versions()
sub script_snappymail_versions sub script_snappymail_versions
{ {
return ( "2.38.0" ); return ( "2.38.2" );
} }
sub script_snappymail_version_desc sub script_snappymail_version_desc

View file

@ -3,7 +3,7 @@
"title": "SnappyMail", "title": "SnappyMail",
"description": "Simple, modern & fast web-based email client", "description": "Simple, modern & fast web-based email client",
"private": true, "private": true,
"version": "2.38.0", "version": "2.38.2",
"homepage": "https://snappymail.eu", "homepage": "https://snappymail.eu",
"author": { "author": {
"name": "DJ Maze", "name": "DJ Maze",

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 { .CompactComposer .squire-toolbar {
padding-top: 4px; padding-top: 4px;
padding-bottom: 0; padding-bottom: 0;

View file

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

View file

@ -17,18 +17,28 @@
addEventListener('rl-view-model', e => { addEventListener('rl-view-model', e => {
const vm = e.detail; const vm = e.detail;
if ('PopupsCompose' === vm.viewModelTemplateID && rl.settings.get('editorWysiwyg') === 'CompactComposer') { 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 // Now move the attachments tab to the bottom of the screen
const const area = vm.querySelector('.tabs .attachmentAreaParent');
input = vm.querySelector('.tabs input[value="attachments"]'), vm.querySelector('.tabs input[value="attachments"]').remove();
label = vm.querySelector('.tabs label[for="tab-attachments"]'), vm.querySelector('.tabs label[for="tab-attachments"]').remove();
area = vm.querySelector('.tabs .attachmentAreaParent'); area.querySelector('.no-attachments-desc').remove();
input.remove();
label.remove();
area.remove();
area.classList.add('compact'); area.classList.add('compact');
area.querySelector('.b-attachment-place').dataset.bind = "visible: addAttachmentEnabled(), css: {dragAndDropOver: dragAndDropVisible}";
vm.viewModelDom.append(area); 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, // There is a better way to do this probably,
// but we need this for drag and drop to work // but we need this for drag and drop to work
e.detail.attachmentsArea = e.detail.bodyArea; e.detail.attachmentsArea = e.detail.bodyArea;
@ -36,10 +46,6 @@
}); });
const 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 // TODO: labels translations
i18n = (str, def) => rl.i18n(str) || def, i18n = (str, def) => rl.i18n(str) || def,
@ -95,21 +101,7 @@
}, },
pasteSanitizer = (event) => { pasteSanitizer = (event) => {
const frag = event.detail.fragment; return rl.Utils.cleanHtml(event.detail.html).html;
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);
}
});
}
});
}, },
pasteImageHandler = (e, squire) => { pasteImageHandler = (e, squire) => {
@ -317,7 +309,8 @@
clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px'; clr.style.left = (input.offsetLeft + input.parentNode.offsetLeft) + 'px';
clr.style.width = input.offsetWidth + '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 = () => { clr.onchange = () => {
switch (name) { switch (name) {
case 'color': case 'color':

View file

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

View file

@ -1,11 +1,13 @@
(rl => { (rl => {
const client_id = rl.pluginSettingsGet('login-o365', 'client_id'), 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'), tenant = rl.pluginSettingsGet('login-o365', 'tenant'),
login = () => { login = () => {
document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({ document.location = 'https://login.microsoftonline.com/'+tenant+'/oauth2/v2.0/authorize?' + (new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: client_id, client_id: client_id,
redirect_uri: document.location.href.replace(/\/$/, '') + '/LoginO365', redirect_uri: document.location.href.replace(/\/$/, '') + '/' + query + 'LoginO365',
scope: [ scope: [
// Associate personal info // Associate personal info
'openid', 'openid',

View file

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

View file

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

View file

@ -21,18 +21,9 @@
// https://github.com/nextcloud/calendar/issues/4684 // https://github.com/nextcloud/calendar/issues/4684
if (cfg.CalDAV) { 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 class="g-ui-link" data-bind="click: nextcloudSaveICS" data-i18n="NEXTCLOUD/SAVE_ICS"></span>
</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.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.nextcloudSaveICS = () => {
view.nextcloudICSCalendar = ko.observable(null);
view.filteredEventsUrls = ko.observable([]);
view.nextcloudSaveICS = async () => {
let VEVENT = view.nextcloudICS(); let VEVENT = view.nextcloudICS();
VEVENT = await view.handleUpdatedRecurrentEvents(VEVENT) VEVENT && rl.nextcloud.selectCalendar()
VEVENT && rl.nextcloud.selectCalendar(VEVENT) .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 * TODO
*/ */
view.message.subscribe(msg => { view.message.subscribe(msg => {
view.nextcloudICS(null); view.nextcloudICS(null);
view.nextcloudICSOldInvitation(null);
view.nextcloudICSNewInvitation(null);
view.nextcloudICSLastInvitation(null);
view.nextcloudICSShow(view.nextcloudICS())
if (msg && cfg.CalDAV) { if (msg && cfg.CalDAV) {
// let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType); // let ics = msg.attachments.find(attachment => 'application/ics' == attachment.mimeType);
let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType); let ics = msg.attachments.find(attachment => 'text/calendar' == attachment.mimeType);
@ -286,7 +126,7 @@
// fetch it and parse the VEVENT // fetch it and parse the VEVENT
rl.fetch(ics.linkDownload()) rl.fetch(ics.linkDownload())
.then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response }))) .then(response => (response.status < 400) ? response.text() : Promise.reject(new Error({ response })))
.then(async (text) => { .then(text => {
let VEVENT, let VEVENT,
VALARM, VALARM,
multiple = ['ATTACH','ATTENDEE','CATEGORIES','COMMENT','CONTACT','EXDATE', multiple = ['ATTACH','ATTENDEE','CATEGORIES','COMMENT','CONTACT','EXDATE',
@ -337,158 +177,6 @@
shouldReply: VEVENT.shouldReply() shouldReply: VEVENT.shouldReply()
}); });
view.nextcloudICS(VEVENT); 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); })(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) { onBuild(dom) {
let modalObj = this
this.tree = dom.querySelector('#sm-nc-calendars'); this.tree = dom.querySelector('#sm-nc-calendars');
this.tree.addEventListener('click', event => { this.tree.addEventListener('click', event => {
let el = event.target; let el = event.target;
if (el.matches('button')) { if (el.matches('button')) {
this.select = el.href; this.select = el.href;
let VEVENT = this.VEVENT this.close();
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()}
])
})
} }
}); });
} }
@ -391,10 +375,9 @@ class NextcloudCalendarsPopupView extends rl.pluginPopupView {
treeElement.appendChild(li); treeElement.appendChild(li);
} }
// Happens after showModal() // Happens after showModal()
beforeShow(fResolve, VEVENT) { beforeShow(fResolve) {
this.select = ''; this.select = '';
this.fResolve = fResolve; this.fResolve = fResolve;
this.VEVENT = VEVENT
this.tree.innerHTML = ''; this.tree.innerHTML = '';
davFetch('calendars', '/', { davFetch('calendars', '/', {
method: 'PROPFIND', method: 'PROPFIND',
@ -449,15 +432,14 @@ close() {}
} }
rl.nextcloud = { rl.nextcloud = {
selectCalendar: (VEVENT) => selectCalendar: () =>
new Promise(resolve => { new Promise(resolve => {
NextcloudCalendarsPopupView.showModal([ NextcloudCalendarsPopupView.showModal([
href => resolve(href), href => resolve(href),
VEVENT
]); ]);
}), }),
calendarPut: (path, event, callback) => { calendarPut: (path, event) => {
davFetch('calendars', path + '/' + event.UID + '.ics', { davFetch('calendars', path + '/' + event.UID + '.ics', {
method: 'PUT', method: 'PUT',
headers: { headers: {
@ -481,8 +463,6 @@ rl.nextcloud = {
// response.text().then(text => console.error({status:response.status, body:text})); // response.text().then(text => console.error({status:response.status, body:text}));
Promise.reject(new Error({ response })); Promise.reject(new Error({ response }));
} }
callback && callback(response)
}); });
}, },
@ -520,27 +500,3 @@ function getElementsInNamespaces(xmlDocument, tagName) {
} }
return results; 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": { "NEXTCLOUD": {
"SAVE_ATTACHMENTS": "Save in Nextcloud", "SAVE_ATTACHMENTS": "Save in Nextcloud",
"SAVE_EML": "Save as .eml in Nextcloud", "SAVE_EML": "Save as .eml in Nextcloud",
"SAVE_ICS": "Add to calendar",
"SELECT_FOLDER": "Select folder", "SELECT_FOLDER": "Select folder",
"SELECT_FILES": "Select file(s)", "SELECT_FILES": "Select file(s)",
"ATTACH_FILES": "Attach Nextcloud files", "ATTACH_FILES": "Attach Nextcloud files",
"SELECT_CALENDAR": "Select calendar",
"FILE_ATTACH": "attach", "FILE_ATTACH": "attach",
"FILE_INTERNAL": "internal", "FILE_INTERNAL": "internal",
"FILE_PUBLIC": "public",
"ADDRESS_BOOK": "Address Book", "ADDRESS_BOOK": "Address Book",
"SELECT_CALENDAR": "Select calendar", "FILE_PUBLIC": "public"
"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"
} }
} }

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" BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not 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_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.\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."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test" TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code" LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Show Secret"
BUTTON_HIDE_SECRET = "Hide Secret" BUTTON_HIDE_SECRET = "Hide Secret"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configured"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Not 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_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.\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."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "You can't change this setting before test."
TITLE_TEST_CODE = "2-Step verification test" TITLE_TEST_CODE = "2-Step verification test"
LABEL_CODE = "Code" LABEL_CODE = "Code"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Titok megjelenítése"
BUTTON_HIDE_SECRET = "Titok elrejtése" BUTTON_HIDE_SECRET = "Titok elrejtése"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Beállítva"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Nincs 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_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.\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."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Tesztelés nélkül nem lehet megváltoztatni ezt a beállítást." 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" TITLE_TEST_CODE = "2-lépéses hitelesítés teszt"
LABEL_CODE = "Kód" LABEL_CODE = "Kód"

View file

@ -12,8 +12,8 @@ BUTTON_SHOW_SECRET = "Mostra la chiave segreta"
BUTTON_HIDE_SECRET = "Nascondi la chiave segreta" BUTTON_HIDE_SECRET = "Nascondi la chiave segreta"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato" TWO_FACTOR_SECRET_CONFIGURED_DESC = "Configurato"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "Non 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_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.\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."
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test." TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "Non puoi modificare questa impostazione prima del test."
TITLE_TEST_CODE = "Test di verifica a 2 fattori" TITLE_TEST_CODE = "Test di verifica a 2 fattori"
LABEL_CODE = "Codice" 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 = "隐藏密钥" BUTTON_HIDE_SECRET = "隐藏密钥"
TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置" TWO_FACTOR_SECRET_CONFIGURED_DESC = "已设置"
TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置" TWO_FACTOR_SECRET_NOT_CONFIGURED_DESC = "未设置"
TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。\n" TWO_FACTOR_SECRET_DESC = "将下面的信息导入 Google Authenticator (或其他 TOTP 客户端)。 扫描下面的二维码或手动输入密钥。"
TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。\n" TWO_FACTOR_BACKUP_CODES_DESC = "如果你无法从验证器获取验证码,你可以使用备用代码登录。 当你使用过某一个备用代码后,它将会失效。"
TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。" TWO_FACTOR_SECRET_TEST_BEFORE_DESC = "完成测试前你无法更改此设置。"
TITLE_TEST_CODE = "两步验证测试" TITLE_TEST_CODE = "两步验证测试"
LABEL_CODE = "代码" LABEL_CODE = "代码"

View file

@ -69,6 +69,7 @@ trait Accounts
} }
/** /**
* Add/Edit additional account
* @throws \MailSo\RuntimeException * @throws \MailSo\RuntimeException
*/ */
public function DoAccountSetup(): array public function DoAccountSetup(): array
@ -209,6 +210,7 @@ trait Accounts
'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(), 'mainEmail' => \RainLoop\Api::Actions()->getMainAccountFromToken()->Email(),
'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(), 'contactsAllowed' => $this->AddressBookProvider($oAccount)->IsActive(),
'HideUnsubscribed' => false, 'HideUnsubscribed' => false,
'defaultSort' => '',
'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false), 'useThreads' => (bool) $oConfig->Get('defaults', 'mail_use_threads', false),
'threadAlgorithm' => '', 'threadAlgorithm' => '',
'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false), 'ReplySameFolder' => (bool) $oConfig->Get('defaults', 'mail_reply_same_folder', false),
@ -225,6 +227,7 @@ trait Accounts
$aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', ''); $aResult['TrashFolder'] = (string) $oSettingsLocal->GetConf('TrashFolder', '');
$aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', ''); $aResult['ArchiveFolder'] = (string) $oSettingsLocal->GetConf('ArchiveFolder', '');
$aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']); $aResult['HideUnsubscribed'] = (bool) $oSettingsLocal->GetConf('HideUnsubscribed', $aResult['HideUnsubscribed']);
$aResult['defaultSort'] = (string) $oSettingsLocal->GetConf('defaultSort', $aResult['defaultSort']);
$aResult['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']); $aResult['useThreads'] = (bool) $oSettingsLocal->GetConf('UseThreads', $aResult['useThreads']);
$aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']); $aResult['threadAlgorithm'] = (string) $oSettingsLocal->GetConf('threadAlgorithm', $aResult['threadAlgorithm']);
$aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']); $aResult['ReplySameFolder'] = (bool) $oSettingsLocal->GetConf('ReplySameFolder', $aResult['ReplySameFolder']);

View file

@ -204,6 +204,7 @@ trait User
$this->setSettingsFromParams($oSettings, 'Resizer5Width', 'int'); $this->setSettingsFromParams($oSettings, 'Resizer5Width', 'int');
$this->setSettingsFromParams($oSettings, 'Resizer5Height', 'int'); $this->setSettingsFromParams($oSettings, 'Resizer5Height', 'int');
$this->setSettingsFromParams($oSettingsLocal, 'defaultSort', 'string');
$this->setSettingsFromParams($oSettingsLocal, 'UseThreads', 'bool'); $this->setSettingsFromParams($oSettingsLocal, 'UseThreads', 'bool');
$this->setSettingsFromParams($oSettingsLocal, 'threadAlgorithm', 'string'); $this->setSettingsFromParams($oSettingsLocal, 'threadAlgorithm', 'string');
$this->setSettingsFromParams($oSettingsLocal, 'ReplySameFolder', 'bool'); $this->setSettingsFromParams($oSettingsLocal, 'ReplySameFolder', 'bool');

View file

@ -141,21 +141,23 @@ trait UserAuth
*/ */
public function LoginProcess(string $sEmail, SensitiveString $oPassword, bool $bMainAccount = true): Account public function LoginProcess(string $sEmail, SensitiveString $oPassword, bool $bMainAccount = true): Account
{ {
$sCredentials = $this->resolveLoginCredentials($sEmail, $oPassword); $aCredentials = $this->resolveLoginCredentials($sEmail, $oPassword);
if (!\str_contains($sCredentials['email'], '@') || !\strlen($oPassword)) { if (!\str_contains($aCredentials['email'], '@') || !\strlen($oPassword)) {
throw new ClientException(Notifications::InvalidInputArgument); throw new ClientException(Notifications::InvalidInputArgument);
} }
$oDomain = $this->DomainProvider()->getByEmailAddress($aCredentials['email']);
$oAccount = null; $oAccount = null;
try { try {
$oAccount = $bMainAccount ? new MainAccount : new AdditionalAccount; $oAccount = $bMainAccount ? new MainAccount : new AdditionalAccount;
$oAccount->setCredentials( $oAccount->setCredentials(
$sCredentials['domain'], $aCredentials['domain'],
$sCredentials['email'], $aCredentials['email'],
$sCredentials['imapUser'], $aCredentials['imapUser'],
$oPassword, $oPassword,
$sCredentials['smtpUser'] $aCredentials['smtpUser']
// ,new SensitiveString($oPassword) // ,new SensitiveString($oPassword)
); );
$this->Plugins()->RunHook('filter.account', array($oAccount)); $this->Plugins()->RunHook('filter.account', array($oAccount));
@ -207,6 +209,7 @@ trait UserAuth
// Test the login // Test the login
$oImapClient = new \MailSo\Imap\ImapClient; $oImapClient = new \MailSo\Imap\ImapClient;
$oImapClient->SetLogger($this->Logger());
$this->imapConnect($oAccount, false, $oImapClient); $this->imapConnect($oAccount, false, $oImapClient);
} }
$this->SetAdditionalAuthToken($oAccount); $this->SetAdditionalAuthToken($oAccount);

View file

@ -165,7 +165,8 @@ abstract class Account implements \JsonSerializable
// $aAccountHash['login'] = $oDomain->ImapSettings()->fixUsername($aAccountHash['login']); // $aAccountHash['login'] = $oDomain->ImapSettings()->fixUsername($aAccountHash['login']);
$oAccount = new static; $oAccount = new static;
$oAccount->sEmail = \SnappyMail\IDN::emailToAscii($aAccountHash['email']); $oAccount->sEmail = \SnappyMail\IDN::emailToAscii($aAccountHash['email']);
$oAccount->sImapUser = \SnappyMail\IDN::emailToAscii($aAccountHash['login']); // $oAccount->sImapUser = \SnappyMail\IDN::emailToAscii($aAccountHash['login']);
$oAccount->sImapUser = $aAccountHash['login'];
$oAccount->setImapPass(new SensitiveString($aAccountHash['pass'])); $oAccount->setImapPass(new SensitiveString($aAccountHash['pass']));
$oAccount->oDomain = $oDomain; $oAccount->oDomain = $oDomain;
$oActions->Plugins()->RunHook('filter.account', array($oAccount)); $oActions->Plugins()->RunHook('filter.account', array($oAccount));
@ -286,7 +287,7 @@ abstract class Account implements \JsonSerializable
*/ */
/** /**
* @deprecated * @deprecated since v2.36.1
*/ */
public function IncLogin() : string public function IncLogin() : string
{ {

View file

@ -106,7 +106,7 @@ class Domain implements \JsonSerializable
public function ValidateWhiteList(string $sEmail) : bool public function ValidateWhiteList(string $sEmail) : bool
{ {
$sW = \trim($this->whiteList); $sW = $this->whiteList;
if (!$sW) { if (!$sW) {
return true; return true;
} }
@ -210,7 +210,7 @@ class Domain implements \JsonSerializable
$oDomain->SMTP->setSender = !empty($aDomain['smtp_set_sender']); $oDomain->SMTP->setSender = !empty($aDomain['smtp_set_sender']);
$oDomain->SMTP->usePhpMail = !empty($aDomain['smtp_php_mail']); $oDomain->SMTP->usePhpMail = !empty($aDomain['smtp_php_mail']);
$oDomain->whiteList = \trim($aDomain['white_list'] ?? ''); $oDomain->whiteList = $aDomain['white_list'] ?? '';
$oDomain->Normalize(); $oDomain->Normalize();
} }

View file

@ -2,19 +2,14 @@
namespace RainLoop\Providers; namespace RainLoop\Providers;
use RainLoop\Notifications;
use RainLoop\Exceptions\ClientException; use RainLoop\Exceptions\ClientException;
class Domain extends AbstractProvider class Domain extends AbstractProvider
{ {
/** private Domain\DomainInterface $oDriver;
* @var Domain\DomainInterface
*/
private $oDriver;
/** private \RainLoop\Plugins\Manager $oPlugins;
* @var \RainLoop\Plugins\Manager
*/
private $oPlugins;
public function __construct(Domain\DomainInterface $oDriver, \RainLoop\Plugins\Manager $oPlugins) public function __construct(Domain\DomainInterface $oDriver, \RainLoop\Plugins\Manager $oPlugins)
{ {
@ -86,10 +81,10 @@ class Domain extends AbstractProvider
{ {
$oDomain = $this->Load(\MailSo\Base\Utils::getEmailAddressDomain($sEmail), true); $oDomain = $this->Load(\MailSo\Base\Utils::getEmailAddressDomain($sEmail), true);
if (!$oDomain) { if (!$oDomain) {
throw new ClientException(Notifications::DomainNotAllowed); throw new ClientException(Notifications::DomainNotAllowed, null, "{$sEmail} has no domain configuration");
} }
if (!$oDomain->ValidateWhiteList($sEmail)) { if (!$oDomain->ValidateWhiteList($sEmail)) {
throw new ClientException(Notifications::AccountNotAllowed); throw new ClientException(Notifications::AccountNotAllowed, null, "{$sEmail} not whitelisted");
} }
return $oDomain; return $oDomain;
} }

View file

@ -2,7 +2,6 @@
namespace Sabre\VObject; namespace Sabre\VObject;
use Sabre\VObject;
use Sabre\Xml; use Sabre\Xml;
/** /**
@ -15,7 +14,7 @@ use Sabre\Xml;
* @author Evert Pot (http://evertpot.com/) * @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License * @license http://sabre.io/license/ Modified BSD License
* *
* @property VObject\Property\FlatText UID * @property Property\FlatText UID
*/ */
class Component extends Node class Component extends Node
{ {

View file

@ -39,13 +39,7 @@ class VAlarm extends VObject\Component
/** @var VEvent|VTodo $parentComponent */ /** @var VEvent|VTodo $parentComponent */
$parentComponent = $this->parent; $parentComponent = $this->parent;
if ('START' === $related) { if ('START' === $related) {
if ('VTODO' === $parentComponent->name) { $effectiveTrigger = $parentComponent->DTSTART->getDateTime();
$propName = 'DUE';
} else {
$propName = 'DTSTART';
}
$effectiveTrigger = $parentComponent->$propName->getDateTime();
} else { } else {
if ('VTODO' === $parentComponent->name) { if ('VTODO' === $parentComponent->name) {
$endProp = 'DUE'; $endProp = 'DUE';

View file

@ -20,8 +20,8 @@ use Sabre\VObject\Recur\NoInstancesException;
* *
* @property VEvent VEVENT * @property VEvent VEVENT
* @property VJournal VJOURNAL * @property VJournal VJOURNAL
* @property VObject\Property\Text ORG * @property Property\Text ORG
* @property VObject\Property\FlatText METHOD * @property Property\FlatText METHOD
*/ */
class VCalendar extends VObject\Document class VCalendar extends VObject\Document
{ {
@ -51,21 +51,21 @@ class VCalendar extends VObject\Document
* List of value-types, and which classes they map to. * List of value-types, and which classes they map to.
*/ */
public static array $valueMap = [ public static array $valueMap = [
'BINARY' => VObject\Property\Binary::class, 'BINARY' => Property\Binary::class,
'BOOLEAN' => VObject\Property\Boolean::class, 'BOOLEAN' => Property\Boolean::class,
'CAL-ADDRESS' => VObject\Property\ICalendar\CalAddress::class, 'CAL-ADDRESS' => Property\ICalendar\CalAddress::class,
'DATE' => VObject\Property\ICalendar\Date::class, 'DATE' => Property\ICalendar\Date::class,
'DATE-TIME' => VObject\Property\ICalendar\DateTime::class, 'DATE-TIME' => Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class, 'DURATION' => Property\ICalendar\Duration::class,
'FLOAT' => VObject\Property\FloatValue::class, 'FLOAT' => Property\FloatValue::class,
'INTEGER' => VObject\Property\IntegerValue::class, 'INTEGER' => Property\IntegerValue::class,
'PERIOD' => VObject\Property\ICalendar\Period::class, 'PERIOD' => Property\ICalendar\Period::class,
'RECUR' => VObject\Property\ICalendar\Recur::class, 'RECUR' => Property\ICalendar\Recur::class,
'TEXT' => VObject\Property\Text::class, 'TEXT' => Property\Text::class,
'TIME' => VObject\Property\Time::class, 'TIME' => Property\Time::class,
'UNKNOWN' => VObject\Property\Unknown::class, // jCard / jCal-only. 'UNKNOWN' => Property\Unknown::class, // jCard / jCal-only.
'URI' => VObject\Property\Uri::class, 'URI' => Property\Uri::class,
'UTC-OFFSET' => VObject\Property\UtcOffset::class, 'UTC-OFFSET' => Property\UtcOffset::class,
]; ];
/** /**
@ -73,78 +73,78 @@ class VCalendar extends VObject\Document
*/ */
public static array $propertyMap = [ public static array $propertyMap = [
// Calendar properties // Calendar properties
'CALSCALE' => VObject\Property\FlatText::class, 'CALSCALE' => Property\FlatText::class,
'METHOD' => VObject\Property\FlatText::class, 'METHOD' => Property\FlatText::class,
'PRODID' => VObject\Property\FlatText::class, 'PRODID' => Property\FlatText::class,
'VERSION' => VObject\Property\FlatText::class, 'VERSION' => Property\FlatText::class,
// Component properties // Component properties
'ATTACH' => VObject\Property\Uri::class, 'ATTACH' => Property\Uri::class,
'CATEGORIES' => VObject\Property\Text::class, 'CATEGORIES' => Property\Text::class,
'CLASS' => VObject\Property\FlatText::class, 'CLASS' => Property\FlatText::class,
'COMMENT' => VObject\Property\FlatText::class, 'COMMENT' => Property\FlatText::class,
'DESCRIPTION' => VObject\Property\FlatText::class, 'DESCRIPTION' => Property\FlatText::class,
'GEO' => VObject\Property\FloatValue::class, 'GEO' => Property\FloatValue::class,
'LOCATION' => VObject\Property\FlatText::class, 'LOCATION' => Property\FlatText::class,
'PERCENT-COMPLETE' => VObject\Property\IntegerValue::class, 'PERCENT-COMPLETE' => Property\IntegerValue::class,
'PRIORITY' => VObject\Property\IntegerValue::class, 'PRIORITY' => Property\IntegerValue::class,
'RESOURCES' => VObject\Property\Text::class, 'RESOURCES' => Property\Text::class,
'STATUS' => VObject\Property\FlatText::class, 'STATUS' => Property\FlatText::class,
'SUMMARY' => VObject\Property\FlatText::class, 'SUMMARY' => Property\FlatText::class,
// Date and Time Component Properties // Date and Time Component Properties
'COMPLETED' => VObject\Property\ICalendar\DateTime::class, 'COMPLETED' => Property\ICalendar\DateTime::class,
'DTEND' => VObject\Property\ICalendar\DateTime::class, 'DTEND' => Property\ICalendar\DateTime::class,
'DUE' => VObject\Property\ICalendar\DateTime::class, 'DUE' => Property\ICalendar\DateTime::class,
'DTSTART' => VObject\Property\ICalendar\DateTime::class, 'DTSTART' => Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class, 'DURATION' => Property\ICalendar\Duration::class,
'FREEBUSY' => VObject\Property\ICalendar\Period::class, 'FREEBUSY' => Property\ICalendar\Period::class,
'TRANSP' => VObject\Property\FlatText::class, 'TRANSP' => Property\FlatText::class,
// Time Zone Component Properties // Time Zone Component Properties
'TZID' => VObject\Property\FlatText::class, 'TZID' => Property\FlatText::class,
'TZNAME' => VObject\Property\FlatText::class, 'TZNAME' => Property\FlatText::class,
'TZOFFSETFROM' => VObject\Property\UtcOffset::class, 'TZOFFSETFROM' => Property\UtcOffset::class,
'TZOFFSETTO' => VObject\Property\UtcOffset::class, 'TZOFFSETTO' => Property\UtcOffset::class,
'TZURL' => VObject\Property\Uri::class, 'TZURL' => Property\Uri::class,
// Relationship Component Properties // Relationship Component Properties
'ATTENDEE' => VObject\Property\ICalendar\CalAddress::class, 'ATTENDEE' => Property\ICalendar\CalAddress::class,
'CONTACT' => VObject\Property\FlatText::class, 'CONTACT' => Property\FlatText::class,
'ORGANIZER' => VObject\Property\ICalendar\CalAddress::class, 'ORGANIZER' => Property\ICalendar\CalAddress::class,
'RECURRENCE-ID' => VObject\Property\ICalendar\DateTime::class, 'RECURRENCE-ID' => Property\ICalendar\DateTime::class,
'RELATED-TO' => VObject\Property\FlatText::class, 'RELATED-TO' => Property\FlatText::class,
'URL' => VObject\Property\Uri::class, 'URL' => Property\Uri::class,
'UID' => VObject\Property\FlatText::class, 'UID' => Property\FlatText::class,
// Recurrence Component Properties // Recurrence Component Properties
'EXDATE' => VObject\Property\ICalendar\DateTime::class, 'EXDATE' => Property\ICalendar\DateTime::class,
'RDATE' => VObject\Property\ICalendar\DateTime::class, 'RDATE' => Property\ICalendar\DateTime::class,
'RRULE' => VObject\Property\ICalendar\Recur::class, 'RRULE' => Property\ICalendar\Recur::class,
'EXRULE' => VObject\Property\ICalendar\Recur::class, // Deprecated since rfc5545 'EXRULE' => Property\ICalendar\Recur::class, // Deprecated since rfc5545
// Alarm Component Properties // Alarm Component Properties
'ACTION' => VObject\Property\FlatText::class, 'ACTION' => Property\FlatText::class,
'REPEAT' => VObject\Property\IntegerValue::class, 'REPEAT' => Property\IntegerValue::class,
'TRIGGER' => VObject\Property\ICalendar\Duration::class, 'TRIGGER' => Property\ICalendar\Duration::class,
// Change Management Component Properties // Change Management Component Properties
'CREATED' => VObject\Property\ICalendar\DateTime::class, 'CREATED' => Property\ICalendar\DateTime::class,
'DTSTAMP' => VObject\Property\ICalendar\DateTime::class, 'DTSTAMP' => Property\ICalendar\DateTime::class,
'LAST-MODIFIED' => VObject\Property\ICalendar\DateTime::class, 'LAST-MODIFIED' => Property\ICalendar\DateTime::class,
'SEQUENCE' => VObject\Property\IntegerValue::class, 'SEQUENCE' => Property\IntegerValue::class,
// Request Status // Request Status
'REQUEST-STATUS' => VObject\Property\Text::class, 'REQUEST-STATUS' => Property\Text::class,
// Additions from draft-daboo-valarm-extensions-04 // Additions from draft-daboo-valarm-extensions-04
'ALARM-AGENT' => VObject\Property\Text::class, 'ALARM-AGENT' => Property\Text::class,
'ACKNOWLEDGED' => VObject\Property\ICalendar\DateTime::class, 'ACKNOWLEDGED' => Property\ICalendar\DateTime::class,
'PROXIMITY' => VObject\Property\Text::class, 'PROXIMITY' => Property\Text::class,
'DEFAULT-ALARM' => VObject\Property\Boolean::class, 'DEFAULT-ALARM' => Property\Boolean::class,
// Additions from draft-daboo-calendar-availability-05 // Additions from draft-daboo-calendar-availability-05
'BUSYTYPE' => VObject\Property\Text::class, 'BUSYTYPE' => Property\Text::class,
]; ];
/** /**
@ -166,10 +166,10 @@ class VCalendar extends VObject\Document
* *
* @return VObject\Component[] * @return VObject\Component[]
*/ */
public function getBaseComponents(string $componentName = null): array public function getBaseComponents(?string $componentName = null): array
{ {
$isBaseComponent = function ($component): bool { $isBaseComponent = function ($component): bool {
if (!$component instanceof VObject\Component) { if (!$component instanceof Component) {
return false; return false;
} }
if ('VTIMEZONE' === $component->name) { if ('VTIMEZONE' === $component->name) {
@ -214,13 +214,11 @@ class VCalendar extends VObject\Document
* If there is no such component, null will be returned. * If there is no such component, null will be returned.
* *
* @param string|null $componentName filter by component name * @param string|null $componentName filter by component name
*
* @return VObject\Component|null
*/ */
public function getBaseComponent(string $componentName = null): ?Component public function getBaseComponent(?string $componentName = null): ?Component
{ {
$isBaseComponent = function ($component): bool { $isBaseComponent = function ($component): bool {
if (!$component instanceof VObject\Component) { if (!$component instanceof Component) {
return false; return false;
} }
if ('VTIMEZONE' === $component->name) { if ('VTIMEZONE' === $component->name) {
@ -275,7 +273,7 @@ class VCalendar extends VObject\Document
* @throws InvalidDataException * @throws InvalidDataException
* @throws VObject\Recur\MaxInstancesExceededException * @throws VObject\Recur\MaxInstancesExceededException
*/ */
public function expand(\DateTimeInterface $start, \DateTimeInterface $end, \DateTimeZone $timeZone = null): VCalendar public function expand(\DateTimeInterface $start, \DateTimeInterface $end, ?\DateTimeZone $timeZone = null): VCalendar
{ {
$newChildren = []; $newChildren = [];
$recurringEvents = []; $recurringEvents = [];

View file

@ -82,7 +82,7 @@ class VCard extends VObject\Document
'ROLE' => VObject\Property\FlatText::class, 'ROLE' => VObject\Property\FlatText::class,
'LOGO' => VObject\Property\Binary::class, 'LOGO' => VObject\Property\Binary::class,
// 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so // 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so
// not supported at the moment // not supported at the moment
'ORG' => VObject\Property\Text::class, 'ORG' => VObject\Property\Text::class,
'NOTE' => VObject\Property\FlatText::class, 'NOTE' => VObject\Property\FlatText::class,
'REV' => VObject\Property\VCard\TimeStamp::class, 'REV' => VObject\Property\VCard\TimeStamp::class,

View file

@ -104,7 +104,7 @@ class VTodo extends VObject\Component
'LAST-MODIFIED' => '?', 'LAST-MODIFIED' => '?',
'LOCATION' => '?', 'LOCATION' => '?',
'ORGANIZER' => '?', 'ORGANIZER' => '?',
'PERCENT' => '?', 'PERCENT-COMPLETE' => '?',
'PRIORITY' => '?', 'PRIORITY' => '?',
'RECURRENCE-ID' => '?', 'RECURRENCE-ID' => '?',
'SEQUENCE' => '?', 'SEQUENCE' => '?',

View file

@ -27,7 +27,7 @@ class DateTimeParser
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public static function parseDateTime(string $dt, \DateTimeZone $tz = null): \DateTimeImmutable public static function parseDateTime(string $dt, ?\DateTimeZone $tz = null): \DateTimeImmutable
{ {
// Format is YYYYMMDD + "T" + hhmmss // Format is YYYYMMDD + "T" + hhmmss
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches); $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches);
@ -54,7 +54,7 @@ class DateTimeParser
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public static function parseDate(string $date, \DateTimeZone $tz = null): \DateTimeImmutable public static function parseDate(string $date, ?\DateTimeZone $tz = null): \DateTimeImmutable
{ {
// Format is YYYYMMDD // Format is YYYYMMDD
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches); $result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
@ -98,12 +98,12 @@ class DateTimeParser
} }
$parts = [ $parts = [
'week', 'week',
'day', 'day',
'hour', 'hour',
'minute', 'minute',
'second', 'second',
]; ];
foreach ($parts as $part) { foreach ($parts as $part) {
$matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0; $matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0;

View file

@ -2,8 +2,6 @@
namespace Sabre\VObject; namespace Sabre\VObject;
use Sabre\VObject;
/** /**
* Document. * Document.
* *
@ -18,7 +16,7 @@ use Sabre\VObject;
* @author Evert Pot (http://evertpot.com/) * @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License * @license http://sabre.io/license/ Modified BSD License
* *
* @property VObject\Property\FlatText VERSION * @property Property\FlatText VERSION
*/ */
abstract class Document extends Component abstract class Document extends Component
{ {
@ -140,7 +138,7 @@ abstract class Document extends Component
* an iCalendar object, this may be something like CALSCALE:GREGORIAN. To * an iCalendar object, this may be something like CALSCALE:GREGORIAN. To
* ensure that this does not happen, set $defaults to false. * ensure that this does not happen, set $defaults to false.
*/ */
public function createComponent(string $name, array $children = null, bool $defaults = true): Component public function createComponent(string $name, ?array $children = null, bool $defaults = true): Component
{ {
$name = strtoupper($name); $name = strtoupper($name);
$class = Component::class; $class = Component::class;
@ -169,7 +167,7 @@ abstract class Document extends Component
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function createProperty(string $name, $value = null, array $parameters = null, string $valueType = null): Property public function createProperty(string $name, $value = null, ?array $parameters = null, ?string $valueType = null, ?int $lineIndex = null, ?string $lineString = null): Property
{ {
// If there's a . in the name, it means it's prefixed by a group name. // If there's a . in the name, it means it's prefixed by a group name.
if (false !== ($i = strpos($name, '.'))) { if (false !== ($i = strpos($name, '.'))) {
@ -203,7 +201,7 @@ abstract class Document extends Component
$parameters = []; $parameters = [];
} }
return new $class($this, $name, $value, $parameters, $group); return new $class($this, $name, $value, $parameters, $group, $lineIndex, $lineString);
} }
/** /**

View file

@ -72,7 +72,7 @@ class FreeBusyGenerator
* Check the setTimeRange and setObjects methods for details about the * Check the setTimeRange and setObjects methods for details about the
* arguments. * arguments.
*/ */
public function __construct(\DateTimeInterface $start = null, \DateTimeInterface $end = null, $objects = null, \DateTimeZone $timeZone = null) public function __construct(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null, $objects = null, ?\DateTimeZone $timeZone = null)
{ {
$this->setTimeRange($start, $end); $this->setTimeRange($start, $end);
@ -138,7 +138,7 @@ class FreeBusyGenerator
* *
* @throws \Exception * @throws \Exception
*/ */
public function setTimeRange(\DateTimeInterface $start = null, \DateTimeInterface $end = null): void public function setTimeRange(?\DateTimeInterface $start = null, ?\DateTimeInterface $end = null): void
{ {
if (!$start) { if (!$start) {
$start = new \DateTimeImmutable(Settings::$minDate); $start = new \DateTimeImmutable(Settings::$minDate);

View file

@ -113,7 +113,7 @@ class Broker
* @throws MaxInstancesExceededException * @throws MaxInstancesExceededException
* @throws NoInstancesException * @throws NoInstancesException
*/ */
public function processMessage(Message $itipMessage, VCalendar $existingObject = null) public function processMessage(Message $itipMessage, ?VCalendar $existingObject = null)
{ {
// We only support events at the moment. // We only support events at the moment.
if ('VEVENT' !== $itipMessage->component) { if ('VEVENT' !== $itipMessage->component) {
@ -268,7 +268,7 @@ class Broker
* This is message from an organizer, and is either a new event * This is message from an organizer, and is either a new event
* invite, or an update to an existing one. * invite, or an update to an existing one.
*/ */
protected function processMessageRequest(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageRequest(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
if (!$existingObject) { if (!$existingObject) {
// This is a new invite, and we're just going to copy over // This is a new invite, and we're just going to copy over
@ -296,7 +296,7 @@ class Broker
* attendee got removed from an event, or an event got cancelled * attendee got removed from an event, or an event got cancelled
* altogether. * altogether.
*/ */
protected function processMessageCancel(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageCancel(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
if (!$existingObject) { if (!$existingObject) {
// The event didn't exist in the first place, so we're just // The event didn't exist in the first place, so we're just
@ -321,7 +321,7 @@ class Broker
* @throws MaxInstancesExceededException * @throws MaxInstancesExceededException
* @throws NoInstancesException * @throws NoInstancesException
*/ */
protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null): ?VCalendar protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null): ?VCalendar
{ {
// A reply can only be processed based on an existing object. // A reply can only be processed based on an existing object.
// If the object is not available, the reply is ignored. // If the object is not available, the reply is ignored.
@ -333,7 +333,10 @@ class Broker
// Finding all the instances the attendee replied to. // Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) { foreach ($itipMessage->message->VEVENT as $vevent) {
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE; $attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue(); $instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) { if (isset($vevent->{'REQUEST-STATUS'})) {
@ -346,7 +349,8 @@ class Broker
// all the instances where we have a reply for. // all the instances where we have a reply for.
$masterObject = null; $masterObject = null;
foreach ($existingObject->VEVENT as $vevent) { foreach ($existingObject->VEVENT as $vevent) {
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
if ('master' === $recurId) { if ('master' === $recurId) {
$masterObject = $vevent; $masterObject = $vevent;
} }
@ -393,7 +397,10 @@ class Broker
$newObject = $recurrenceIterator->getEventObject(); $newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next(); $recurrenceIterator->next();
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue() === $recurId) { // Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true; $found = true;
} }
--$iterations; --$iterations;
@ -496,10 +503,11 @@ class Broker
$icalMsg->add(clone $timezone); $icalMsg->add(clone $timezone);
} }
if (!$attendee['newInstances']) { if (!$attendee['newInstances'] || 'CANCELLED' === $eventInfo['status']) {
// If there are no instances the attendee is a part of, it // If there are no instances the attendee is a part of, it means
// means the attendee was removed and we need to send him a // the attendee was removed and we need to send them a CANCEL message.
// CANCEL. // Also If the meeting STATUS property was changed to CANCELLED
// we need to send the attendee a CANCEL message.
$message->method = 'CANCEL'; $message->method = 'CANCEL';
$icalMsg->METHOD = $message->method; $icalMsg->METHOD = $message->method;

View file

@ -131,7 +131,7 @@ class Json extends Parser
list( list(
$propertyName, $propertyName,
$parameters, $parameters,
$valueType $valueType,
) = $jProp; ) = $jProp;
$propertyName = strtoupper($propertyName); $propertyName = strtoupper($propertyName);

View file

@ -79,6 +79,12 @@ class MimeDir extends Parser
$this->setInput($input); $this->setInput($input);
} }
if (!\is_resource($this->input)) {
// Null was passed as input, but there was no existing input buffer
// There is nothing to parse.
throw new ParseException('No input provided to parse');
}
if (0 !== $options) { if (0 !== $options) {
$this->options = $options; $this->options = $options;
} }
@ -445,7 +451,7 @@ class MimeDir extends Parser
} }
} }
$propObj = $this->root->createProperty($property['name'], null, $namedParameters); $propObj = $this->root->createProperty($property['name'], null, $namedParameters, null, $this->startLine, $line);
foreach ($namelessParameters as $namelessParameter) { foreach ($namelessParameters as $namelessParameter) {
$propObj->add(null, $namelessParameter); $propObj->add(null, $namelessParameter);

View file

@ -51,6 +51,20 @@ abstract class Property extends Node
*/ */
public string $delimiter = ';'; public string $delimiter = ';';
/**
* The line number in the original iCalendar / vCard file
* that corresponds with the current node
* if the node was read from a file.
*/
public ?int $lineIndex;
/**
* The line string from the original iCalendar / vCard file
* that corresponds with the current node
* if the node was read from a file.
*/
public ?string $lineString;
/** /**
* Creates the generic property. * Creates the generic property.
* *
@ -61,7 +75,7 @@ abstract class Property extends Node
* @param array $parameters List of parameters * @param array $parameters List of parameters
* @param string|null $group The vcard property group * @param string|null $group The vcard property group
*/ */
public function __construct(Component $root, ?string $name, $value = null, array $parameters = [], string $group = null) public function __construct(Component $root, ?string $name, $value = null, array $parameters = [], ?string $group = null, ?int $lineIndex = null, ?string $lineString = null)
{ {
$this->name = $name; $this->name = $name;
$this->group = $group; $this->group = $group;
@ -75,6 +89,14 @@ abstract class Property extends Node
if (!is_null($value)) { if (!is_null($value)) {
$this->setValue($value); $this->setValue($value);
} }
if (!is_null($lineIndex)) {
$this->lineIndex = $lineIndex;
}
if (!is_null($lineString)) {
$this->lineString = $lineString;
}
} }
/** /**

View file

@ -128,7 +128,7 @@ class DateTime extends Property
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function getDateTime(\DateTimeZone $timeZone = null): ?\DateTimeImmutable public function getDateTime(?\DateTimeZone $timeZone = null): ?\DateTimeImmutable
{ {
$dt = $this->getDateTimes($timeZone); $dt = $this->getDateTimes($timeZone);
if (!$dt) { if (!$dt) {
@ -149,7 +149,7 @@ class DateTime extends Property
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function getDateTimes(\DateTimeZone $timeZone = null): array public function getDateTimes(?\DateTimeZone $timeZone = null): array
{ {
// Does the property have a TZID? // Does the property have a TZID?
/** @var Property\FlatText $tzid */ /** @var Property\FlatText $tzid */

View file

@ -189,7 +189,14 @@ class Recur extends Property
if (empty($part)) { if (empty($part)) {
continue; continue;
} }
list($partName, $partValue) = explode('=', $part);
$parts = explode('=', $part);
if (2 !== count($parts)) {
throw new InvalidDataException('The supplied iCalendar RRULE part is incorrect: '.$part);
}
list($partName, $partValue) = $parts;
// The value itself had multiple values.. // The value itself had multiple values..
if (false !== strpos($partValue, ',')) { if (false !== strpos($partValue, ',')) {

View file

@ -64,7 +64,7 @@ class Text extends Property
* @param array $parameters List of parameters * @param array $parameters List of parameters
* @param string|null $group The vcard property group * @param string|null $group The vcard property group
*/ */
public function __construct(Component $root, string $name, $value = null, array $parameters = [], string $group = null) public function __construct(Component $root, string $name, $value = null, array $parameters = [], ?string $group = null)
{ {
// There's two types of multi-valued text properties: // There's two types of multi-valued text properties:
// 1. multivalue properties. // 1. multivalue properties.

View file

@ -89,7 +89,7 @@ class EventIterator implements \Iterator
* @throws NoInstancesException * @throws NoInstancesException
* @throws InvalidDataException * @throws InvalidDataException
*/ */
public function __construct($input, string $uid = null, \DateTimeZone $timeZone = null) public function __construct($input, ?string $uid = null, ?\DateTimeZone $timeZone = null)
{ {
if (is_null($timeZone)) { if (is_null($timeZone)) {
$timeZone = new \DateTimeZone('UTC'); $timeZone = new \DateTimeZone('UTC');

View file

@ -159,6 +159,14 @@ class RRuleIterator implements \Iterator
*/ */
protected ?\DateTimeInterface $currentDate; protected ?\DateTimeInterface $currentDate;
/**
* The number of hours that the next occurrence of an event
* jumped forward, usually because summer time started and
* the requested time-of-day like 0230 did not exist on that
* day. And so the event was scheduled 1 hour later at 0330.
*/
protected int $hourJump = 0;
/** /**
* Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly, * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
* yearly. * yearly.
@ -276,12 +284,65 @@ class RRuleIterator implements \Iterator
/* Functions that advance the iterator {{{ */ /* Functions that advance the iterator {{{ */
/**
* Gets the original start time of the RRULE.
*
* The value is formatted as a string with 24-hour:minute:second
*/
protected function startTime(): string
{
return $this->startDate->format('H:i:s');
}
/**
* Advances currentDate by the interval.
* The time is set from the original startDate.
* If the recurrence is on a day when summer time started, then the
* time on that day may have jumped forward, for example, from 0230 to 0330.
* Using the original time means that the next recurrence will be calculated
* based on the original start time and the day/week/month/year interval.
* So the start time of the next occurrence can correctly revert to 0230.
*/
protected function advanceTheDate(string $interval): void
{
$this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime());
}
/**
* Does the processing for adjusting the time of multi-hourly events when summer time starts.
*/
protected function adjustForTimeJumpsOfHourlyEvent(\DateTimeInterface $previousEventDateTime): void
{
if (0 === $this->hourJump) {
// Remember if the clock time jumped forward on the next occurrence.
// That happens if the next event time is on a day when summer time starts
// and the event time is in the non-existent hour of the day.
// For example, an event that normally starts at 02:30 will
// have to start at 03:30 on that day.
// If the interval is just 1 hour, then there is no "jumping back" to do.
// The events that day will happen, for example, at 0030 0130 0330 0430 0530...
if ($this->interval > 1) {
$expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24;
$actualHourOfNextDate = (int) $this->currentDate->format('G');
$this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate;
}
} else {
// The hour "jumped" for the previous occurrence, to avoid the non-existent time.
// currentDate got set ahead by (usually) 1 hour on that day.
// Adjust it back for this next occurrence.
$this->currentDate = $this->currentDate->sub(new \DateInterval('PT'.$this->hourJump.'H'));
$this->hourJump = 0;
}
}
/** /**
* Does the processing for advancing the iterator for hourly frequency. * Does the processing for advancing the iterator for hourly frequency.
*/ */
protected function nextHourly(): void protected function nextHourly(): void
{ {
$previousEventDateTime = clone $this->currentDate;
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours'); $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours');
$this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime);
} }
/** /**
@ -290,7 +351,7 @@ class RRuleIterator implements \Iterator
protected function nextDaily(): void protected function nextDaily(): void
{ {
if (!$this->byHour && !$this->byDay) { if (!$this->byHour && !$this->byDay) {
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' days'); $this->advanceTheDate('+'.$this->interval.' days');
return; return;
} }
@ -349,7 +410,7 @@ class RRuleIterator implements \Iterator
protected function nextWeekly(): void protected function nextWeekly(): void
{ {
if (!$this->byHour && !$this->byDay) { if (!$this->byHour && !$this->byDay) {
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' weeks'); $this->advanceTheDate('+'.$this->interval.' weeks');
return; return;
} }
@ -371,7 +432,7 @@ class RRuleIterator implements \Iterator
if ($this->byHour) { if ($this->byHour) {
$this->currentDate = $this->currentDate->modify('+1 hours'); $this->currentDate = $this->currentDate->modify('+1 hours');
} else { } else {
$this->currentDate = $this->currentDate->modify('+1 days'); $this->advanceTheDate('+1 days');
} }
// Current day of the week // Current day of the week
@ -408,13 +469,13 @@ class RRuleIterator implements \Iterator
// occur to the next month. We Must skip these invalid // occur to the next month. We Must skip these invalid
// entries. // entries.
if ($currentDayOfMonth < 29) { if ($currentDayOfMonth < 29) {
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' months'); $this->advanceTheDate('+'.$this->interval.' months');
} else { } else {
$increase = 0; $increase = 0;
do { do {
++$increase; ++$increase;
$tempDate = clone $this->currentDate; $tempDate = clone $this->currentDate;
$tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months'); $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime());
} while ($tempDate->format('j') != $currentDayOfMonth); } while ($tempDate->format('j') != $currentDayOfMonth);
$this->currentDate = $tempDate; $this->currentDate = $tempDate;
} }
@ -465,11 +526,15 @@ class RRuleIterator implements \Iterator
} }
} }
// Set the currentDate to the year and month that we are in, and the day of the month that we have selected.
// That day could be a day when summer time starts, and if the time of the event is, for example, 0230,
// then 0230 will not be a valid time on that day. So always apply the start time from the original startDate.
// The "modify" method will set the time forward to 0330, for example, if needed.
$this->currentDate = $this->currentDate->setDate( $this->currentDate = $this->currentDate->setDate(
(int) $this->currentDate->format('Y'), (int) $this->currentDate->format('Y'),
(int) $this->currentDate->format('n'), (int) $this->currentDate->format('n'),
(int) $occurrence (int) $occurrence
); )->modify($this->startTime());
} }
/** /**
@ -586,7 +651,7 @@ class RRuleIterator implements \Iterator
} }
// The easiest form // The easiest form
$this->currentDate = $this->currentDate->modify('+'.$this->interval.' years'); $this->advanceTheDate('+'.$this->interval.' years');
return; return;
} }
@ -610,7 +675,11 @@ class RRuleIterator implements \Iterator
// If we advanced to the next month or year, the first // If we advanced to the next month or year, the first
// occurrence is always correct. // occurrence is always correct.
if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) { if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
break 2; // only consider byMonth matches,
// otherwise, we don't follow RRule correctly
if (in_array($currentMonth, $this->byMonth)) {
break 2;
}
} }
} }
@ -646,7 +715,7 @@ class RRuleIterator implements \Iterator
(int) $currentYear, (int) $currentYear,
(int) $currentMonth, (int) $currentMonth,
(int) $occurrence (int) $occurrence
); )->modify($this->startTime());
return; return;
} else { } else {
@ -663,7 +732,7 @@ class RRuleIterator implements \Iterator
(int) $currentYear, (int) $currentYear,
(int) $currentMonth, (int) $currentMonth,
(int) $currentDayOfMonth (int) $currentDayOfMonth
); )->modify($this->startTime());
return; return;
} }

View file

@ -46,7 +46,7 @@ class ICalendar implements SplitterInterface
{ {
$data = VObject\Reader::read($input, $options); $data = VObject\Reader::read($input, $options);
if (!$data instanceof VObject\Component\VCalendar) { if (!$data instanceof VCalendar) {
throw new VObject\ParseException('Supplied input could not be parsed as VCALENDAR.'); throw new VObject\ParseException('Supplied input could not be parsed as VCALENDAR.');
} }

View file

@ -61,7 +61,7 @@ class VCard implements SplitterInterface
try { try {
$object = $this->parser->parse(); $object = $this->parser->parse();
if (!$object instanceof VObject\Component\VCard) { if (!$object instanceof Component\VCard) {
throw new VObject\ParseException('The supplied input contained non-VCARD data.'); throw new VObject\ParseException('The supplied input contained non-VCARD data.');
} }
} catch (VObject\EofException $e) { } catch (VObject\EofException $e) {

View file

@ -72,7 +72,7 @@ class TimeZoneUtil
* Alternatively, if $failIfUncertain is set to true, it will throw an * Alternatively, if $failIfUncertain is set to true, it will throw an
* exception if we cannot accurately determine the timezone. * exception if we cannot accurately determine the timezone.
*/ */
private function findTimeZone(string $tzid, Component $vcalendar = null, bool $failIfUncertain = false): \DateTimeZone private function findTimeZone(string $tzid, ?Component $vcalendar = null, bool $failIfUncertain = false): \DateTimeZone
{ {
foreach ($this->timezoneFinders as $timezoneFinder) { foreach ($this->timezoneFinders as $timezoneFinder) {
$timezone = $timezoneFinder->find($tzid, $failIfUncertain); $timezone = $timezoneFinder->find($tzid, $failIfUncertain);
@ -117,7 +117,7 @@ class TimeZoneUtil
self::getInstance()->addFinder($key, $finder); self::getInstance()->addFinder($key, $finder);
} }
public static function getTimeZone(string $tzid, Component $vcalendar = null, bool $failIfUncertain = false): \DateTimeZone public static function getTimeZone(string $tzid, ?Component $vcalendar = null, bool $failIfUncertain = false): \DateTimeZone
{ {
return self::getInstance()->findTimeZone($tzid, $vcalendar, $failIfUncertain); return self::getInstance()->findTimeZone($tzid, $vcalendar, $failIfUncertain);
} }

View file

@ -93,8 +93,8 @@ class VCardConverter
); );
if (Document::VCARD30 === $targetVersion) { if (Document::VCARD30 === $targetVersion) {
if ($property instanceof Property\Uri && in_array($property->name, ['PHOTO', 'LOGO', 'SOUND'])) { if ($property instanceof Uri && in_array($property->name, ['PHOTO', 'LOGO', 'SOUND'])) {
/** @var Property\Uri $newProperty */ /** @var Uri $newProperty */
$newProperty = $this->convertUriToBinary($output, $newProperty); $newProperty = $this->convertUriToBinary($output, $newProperty);
} elseif ($property instanceof Property\VCard\DateAndOrTime) { } elseif ($property instanceof Property\VCard\DateAndOrTime) {
// In vCard 4, the birth year may be optional. This is not the // In vCard 4, the birth year may be optional. This is not the
@ -153,8 +153,8 @@ class VCardConverter
return; return;
} }
if ($property instanceof Property\Binary) { if ($property instanceof Binary) {
/** @var Property\Binary $newProperty */ /** @var Binary $newProperty */
$newProperty = $this->convertBinaryToUri($output, $newProperty, $parameters); $newProperty = $this->convertBinaryToUri($output, $newProperty, $parameters);
} elseif ($property instanceof Property\VCard\DateAndOrTime && isset($parameters['X-APPLE-OMIT-YEAR'])) { } elseif ($property instanceof Property\VCard\DateAndOrTime && isset($parameters['X-APPLE-OMIT-YEAR'])) {
// If a property such as BDAY contained 'X-APPLE-OMIT-YEAR', // If a property such as BDAY contained 'X-APPLE-OMIT-YEAR',
@ -256,7 +256,7 @@ class VCardConverter
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
protected function convertBinaryToUri(Component\VCard $output, Property\Binary $newProperty, array &$parameters): Uri protected function convertBinaryToUri(Component\VCard $output, Binary $newProperty, array &$parameters): Uri
{ {
$value = $newProperty->getValue(); $value = $newProperty->getValue();
/** @var Uri $newProperty */ /** @var Uri $newProperty */
@ -304,11 +304,11 @@ class VCardConverter
* be valid in vCard 3.0 as well, we should convert those to BINARY if * be valid in vCard 3.0 as well, we should convert those to BINARY if
* possible, to improve compatibility. * possible, to improve compatibility.
* *
* @return Property\Binary|Property\Uri|null * @return Binary|Uri|null
* *
* @throws InvalidDataException * @throws InvalidDataException
*/ */
protected function convertUriToBinary(Component\VCard $output, Property\Uri $newProperty): Property protected function convertUriToBinary(Component\VCard $output, Uri $newProperty): Property
{ {
$value = $newProperty->getValue(); $value = $newProperty->getValue();

View file

@ -11,142 +11,142 @@
*/ */
return [ return [
'AUS Central Standard Time' => 'Australia/Darwin', 'AUS Central Standard Time' => 'Australia/Darwin',
'AUS Eastern Standard Time' => 'Australia/Sydney', 'AUS Eastern Standard Time' => 'Australia/Sydney',
'Afghanistan Standard Time' => 'Asia/Kabul', 'Afghanistan Standard Time' => 'Asia/Kabul',
'Alaskan Standard Time' => 'America/Anchorage', 'Alaskan Standard Time' => 'America/Anchorage',
'Aleutian Standard Time' => 'America/Adak', 'Aleutian Standard Time' => 'America/Adak',
'Altai Standard Time' => 'Asia/Barnaul', 'Altai Standard Time' => 'Asia/Barnaul',
'Arab Standard Time' => 'Asia/Riyadh', 'Arab Standard Time' => 'Asia/Riyadh',
'Arabian Standard Time' => 'Asia/Dubai', 'Arabian Standard Time' => 'Asia/Dubai',
'Arabic Standard Time' => 'Asia/Baghdad', 'Arabic Standard Time' => 'Asia/Baghdad',
'Argentina Standard Time' => 'America/Buenos_Aires', 'Argentina Standard Time' => 'America/Buenos_Aires',
'Astrakhan Standard Time' => 'Europe/Astrakhan', 'Astrakhan Standard Time' => 'Europe/Astrakhan',
'Atlantic Standard Time' => 'America/Halifax', 'Atlantic Standard Time' => 'America/Halifax',
'Aus Central W. Standard Time' => 'Australia/Eucla', 'Aus Central W. Standard Time' => 'Australia/Eucla',
'Azerbaijan Standard Time' => 'Asia/Baku', 'Azerbaijan Standard Time' => 'Asia/Baku',
'Azores Standard Time' => 'Atlantic/Azores', 'Azores Standard Time' => 'Atlantic/Azores',
'Bahia Standard Time' => 'America/Bahia', 'Bahia Standard Time' => 'America/Bahia',
'Bangladesh Standard Time' => 'Asia/Dhaka', 'Bangladesh Standard Time' => 'Asia/Dhaka',
'Belarus Standard Time' => 'Europe/Minsk', 'Belarus Standard Time' => 'Europe/Minsk',
'Bougainville Standard Time' => 'Pacific/Bougainville', 'Bougainville Standard Time' => 'Pacific/Bougainville',
'Canada Central Standard Time' => 'America/Regina', 'Canada Central Standard Time' => 'America/Regina',
'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
'Caucasus Standard Time' => 'Asia/Yerevan', 'Caucasus Standard Time' => 'Asia/Yerevan',
'Cen. Australia Standard Time' => 'Australia/Adelaide', 'Cen. Australia Standard Time' => 'Australia/Adelaide',
'Central America Standard Time' => 'America/Guatemala', 'Central America Standard Time' => 'America/Guatemala',
'Central Asia Standard Time' => 'Asia/Almaty', 'Central Asia Standard Time' => 'Asia/Almaty',
'Central Brazilian Standard Time' => 'America/Cuiaba', 'Central Brazilian Standard Time' => 'America/Cuiaba',
'Central Europe Standard Time' => 'Europe/Budapest', 'Central Europe Standard Time' => 'Europe/Budapest',
'Central European Standard Time' => 'Europe/Warsaw', 'Central European Standard Time' => 'Europe/Warsaw',
'Central Pacific Standard Time' => 'Pacific/Guadalcanal', 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
'Central Standard Time' => 'America/Chicago', 'Central Standard Time' => 'America/Chicago',
'Central Standard Time (Mexico)' => 'America/Mexico_City', 'Central Standard Time (Mexico)' => 'America/Mexico_City',
'Chatham Islands Standard Time' => 'Pacific/Chatham', 'Chatham Islands Standard Time' => 'Pacific/Chatham',
'China Standard Time' => 'Asia/Shanghai', 'China Standard Time' => 'Asia/Shanghai',
'Cuba Standard Time' => 'America/Havana', 'Cuba Standard Time' => 'America/Havana',
'Dateline Standard Time' => 'Etc/GMT+12', 'Dateline Standard Time' => 'Etc/GMT+12',
'E. Africa Standard Time' => 'Africa/Nairobi', 'E. Africa Standard Time' => 'Africa/Nairobi',
'E. Australia Standard Time' => 'Australia/Brisbane', 'E. Australia Standard Time' => 'Australia/Brisbane',
'E. Europe Standard Time' => 'Europe/Chisinau', 'E. Europe Standard Time' => 'Europe/Chisinau',
'E. South America Standard Time' => 'America/Sao_Paulo', 'E. South America Standard Time' => 'America/Sao_Paulo',
'Easter Island Standard Time' => 'Pacific/Easter', 'Easter Island Standard Time' => 'Pacific/Easter',
'Eastern Standard Time' => 'America/New_York', 'Eastern Standard Time' => 'America/New_York',
'Eastern Standard Time (Mexico)' => 'America/Cancun', 'Eastern Standard Time (Mexico)' => 'America/Cancun',
'Egypt Standard Time' => 'Africa/Cairo', 'Egypt Standard Time' => 'Africa/Cairo',
'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
'FLE Standard Time' => 'Europe/Kiev', 'FLE Standard Time' => 'Europe/Kiev',
'Fiji Standard Time' => 'Pacific/Fiji', 'Fiji Standard Time' => 'Pacific/Fiji',
'GMT Standard Time' => 'Europe/London', 'GMT Standard Time' => 'Europe/London',
'GTB Standard Time' => 'Europe/Bucharest', 'GTB Standard Time' => 'Europe/Bucharest',
'Georgian Standard Time' => 'Asia/Tbilisi', 'Georgian Standard Time' => 'Asia/Tbilisi',
'Greenland Standard Time' => 'America/Godthab', 'Greenland Standard Time' => 'America/Godthab',
'Greenwich Standard Time' => 'Atlantic/Reykjavik', 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
'Haiti Standard Time' => 'America/Port-au-Prince', 'Haiti Standard Time' => 'America/Port-au-Prince',
'Hawaiian Standard Time' => 'Pacific/Honolulu', 'Hawaiian Standard Time' => 'Pacific/Honolulu',
'India Standard Time' => 'Asia/Calcutta', 'India Standard Time' => 'Asia/Calcutta',
'Iran Standard Time' => 'Asia/Tehran', 'Iran Standard Time' => 'Asia/Tehran',
'Israel Standard Time' => 'Asia/Jerusalem', 'Israel Standard Time' => 'Asia/Jerusalem',
'Jordan Standard Time' => 'Asia/Amman', 'Jordan Standard Time' => 'Asia/Amman',
'Kaliningrad Standard Time' => 'Europe/Kaliningrad', 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
'Korea Standard Time' => 'Asia/Seoul', 'Korea Standard Time' => 'Asia/Seoul',
'Libya Standard Time' => 'Africa/Tripoli', 'Libya Standard Time' => 'Africa/Tripoli',
'Line Islands Standard Time' => 'Pacific/Kiritimati', 'Line Islands Standard Time' => 'Pacific/Kiritimati',
'Lord Howe Standard Time' => 'Australia/Lord_Howe', 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
'Magadan Standard Time' => 'Asia/Magadan', 'Magadan Standard Time' => 'Asia/Magadan',
'Magallanes Standard Time' => 'America/Punta_Arenas', 'Magallanes Standard Time' => 'America/Punta_Arenas',
'Marquesas Standard Time' => 'Pacific/Marquesas', 'Marquesas Standard Time' => 'Pacific/Marquesas',
'Mauritius Standard Time' => 'Indian/Mauritius', 'Mauritius Standard Time' => 'Indian/Mauritius',
'Middle East Standard Time' => 'Asia/Beirut', 'Middle East Standard Time' => 'Asia/Beirut',
'Montevideo Standard Time' => 'America/Montevideo', 'Montevideo Standard Time' => 'America/Montevideo',
'Morocco Standard Time' => 'Africa/Casablanca', 'Morocco Standard Time' => 'Africa/Casablanca',
'Mountain Standard Time' => 'America/Denver', 'Mountain Standard Time' => 'America/Denver',
'Mountain Standard Time (Mexico)' => 'America/Chihuahua', 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
'Myanmar Standard Time' => 'Asia/Rangoon', 'Myanmar Standard Time' => 'Asia/Rangoon',
'N. Central Asia Standard Time' => 'Asia/Novosibirsk', 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
'Namibia Standard Time' => 'Africa/Windhoek', 'Namibia Standard Time' => 'Africa/Windhoek',
'Nepal Standard Time' => 'Asia/Katmandu', 'Nepal Standard Time' => 'Asia/Katmandu',
'New Zealand Standard Time' => 'Pacific/Auckland', 'New Zealand Standard Time' => 'Pacific/Auckland',
'Newfoundland Standard Time' => 'America/St_Johns', 'Newfoundland Standard Time' => 'America/St_Johns',
'Norfolk Standard Time' => 'Pacific/Norfolk', 'Norfolk Standard Time' => 'Pacific/Norfolk',
'North Asia East Standard Time' => 'Asia/Irkutsk', 'North Asia East Standard Time' => 'Asia/Irkutsk',
'North Asia Standard Time' => 'Asia/Krasnoyarsk', 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
'North Korea Standard Time' => 'Asia/Pyongyang', 'North Korea Standard Time' => 'Asia/Pyongyang',
'Omsk Standard Time' => 'Asia/Omsk', 'Omsk Standard Time' => 'Asia/Omsk',
'Pacific SA Standard Time' => 'America/Santiago', 'Pacific SA Standard Time' => 'America/Santiago',
'Pacific Standard Time' => 'America/Los_Angeles', 'Pacific Standard Time' => 'America/Los_Angeles',
'Pacific Standard Time (Mexico)' => 'America/Tijuana', 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
'Pakistan Standard Time' => 'Asia/Karachi', 'Pakistan Standard Time' => 'Asia/Karachi',
'Paraguay Standard Time' => 'America/Asuncion', 'Paraguay Standard Time' => 'America/Asuncion',
'Qyzylorda Standard Time' => 'Asia/Qyzylorda', 'Qyzylorda Standard Time' => 'Asia/Qyzylorda',
'Romance Standard Time' => 'Europe/Paris', 'Romance Standard Time' => 'Europe/Paris',
'Russia Time Zone 10' => 'Asia/Srednekolymsk', 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
'Russia Time Zone 11' => 'Asia/Kamchatka', 'Russia Time Zone 11' => 'Asia/Kamchatka',
'Russia Time Zone 3' => 'Europe/Samara', 'Russia Time Zone 3' => 'Europe/Samara',
'Russian Standard Time' => 'Europe/Moscow', 'Russian Standard Time' => 'Europe/Moscow',
'SA Eastern Standard Time' => 'America/Cayenne', 'SA Eastern Standard Time' => 'America/Cayenne',
'SA Pacific Standard Time' => 'America/Bogota', 'SA Pacific Standard Time' => 'America/Bogota',
'SA Western Standard Time' => 'America/La_Paz', 'SA Western Standard Time' => 'America/La_Paz',
'SE Asia Standard Time' => 'Asia/Bangkok', 'SE Asia Standard Time' => 'Asia/Bangkok',
'Saint Pierre Standard Time' => 'America/Miquelon', 'Saint Pierre Standard Time' => 'America/Miquelon',
'Sakhalin Standard Time' => 'Asia/Sakhalin', 'Sakhalin Standard Time' => 'Asia/Sakhalin',
'Samoa Standard Time' => 'Pacific/Apia', 'Samoa Standard Time' => 'Pacific/Apia',
'Sao Tome Standard Time' => 'Africa/Sao_Tome', 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
'Saratov Standard Time' => 'Europe/Saratov', 'Saratov Standard Time' => 'Europe/Saratov',
'Singapore Standard Time' => 'Asia/Singapore', 'Singapore Standard Time' => 'Asia/Singapore',
'South Africa Standard Time' => 'Africa/Johannesburg', 'South Africa Standard Time' => 'Africa/Johannesburg',
'Sri Lanka Standard Time' => 'Asia/Colombo', 'Sri Lanka Standard Time' => 'Asia/Colombo',
'Sudan Standard Time' => 'Africa/Khartoum', 'Sudan Standard Time' => 'Africa/Khartoum',
'Syria Standard Time' => 'Asia/Damascus', 'Syria Standard Time' => 'Asia/Damascus',
'Taipei Standard Time' => 'Asia/Taipei', 'Taipei Standard Time' => 'Asia/Taipei',
'Tasmania Standard Time' => 'Australia/Hobart', 'Tasmania Standard Time' => 'Australia/Hobart',
'Tocantins Standard Time' => 'America/Araguaina', 'Tocantins Standard Time' => 'America/Araguaina',
'Tokyo Standard Time' => 'Asia/Tokyo', 'Tokyo Standard Time' => 'Asia/Tokyo',
'Tomsk Standard Time' => 'Asia/Tomsk', 'Tomsk Standard Time' => 'Asia/Tomsk',
'Tonga Standard Time' => 'Pacific/Tongatapu', 'Tonga Standard Time' => 'Pacific/Tongatapu',
'Transbaikal Standard Time' => 'Asia/Chita', 'Transbaikal Standard Time' => 'Asia/Chita',
'Turkey Standard Time' => 'Europe/Istanbul', 'Turkey Standard Time' => 'Europe/Istanbul',
'Turks And Caicos Standard Time' => 'America/Grand_Turk', 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
'US Eastern Standard Time' => 'America/Indianapolis', 'US Eastern Standard Time' => 'America/Indianapolis',
'US Mountain Standard Time' => 'America/Phoenix', 'US Mountain Standard Time' => 'America/Phoenix',
'UTC' => 'Etc/GMT', 'UTC' => 'Etc/GMT',
'UTC+12' => 'Etc/GMT-12', 'UTC+12' => 'Etc/GMT-12',
'UTC+13' => 'Etc/GMT-13', 'UTC+13' => 'Etc/GMT-13',
'UTC-02' => 'Etc/GMT+2', 'UTC-02' => 'Etc/GMT+2',
'UTC-08' => 'Etc/GMT+8', 'UTC-08' => 'Etc/GMT+8',
'UTC-09' => 'Etc/GMT+9', 'UTC-09' => 'Etc/GMT+9',
'UTC-11' => 'Etc/GMT+11', 'UTC-11' => 'Etc/GMT+11',
'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
'Venezuela Standard Time' => 'America/Caracas', 'Venezuela Standard Time' => 'America/Caracas',
'Vladivostok Standard Time' => 'Asia/Vladivostok', 'Vladivostok Standard Time' => 'Asia/Vladivostok',
'Volgograd Standard Time' => 'Europe/Volgograd', 'Volgograd Standard Time' => 'Europe/Volgograd',
'W. Australia Standard Time' => 'Australia/Perth', 'W. Australia Standard Time' => 'Australia/Perth',
'W. Central Africa Standard Time' => 'Africa/Lagos', 'W. Central Africa Standard Time' => 'Africa/Lagos',
'W. Europe Standard Time' => 'Europe/Berlin', 'W. Europe Standard Time' => 'Europe/Berlin',
'W. Mongolia Standard Time' => 'Asia/Hovd', 'W. Mongolia Standard Time' => 'Asia/Hovd',
'West Asia Standard Time' => 'Asia/Tashkent', 'West Asia Standard Time' => 'Asia/Tashkent',
'West Bank Standard Time' => 'Asia/Hebron', 'West Bank Standard Time' => 'Asia/Hebron',
'West Pacific Standard Time' => 'Pacific/Port_Moresby', 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
'Yakutsk Standard Time' => 'Asia/Yakutsk', 'Yakutsk Standard Time' => 'Asia/Yakutsk',
'Yukon Standard Time' => 'America/Whitehorse', 'Yukon Standard Time' => 'America/Whitehorse',
]; ];

View file

@ -110,7 +110,7 @@ trait ContextStackTrait
$this->elementMap, $this->elementMap,
$this->contextUri, $this->contextUri,
$this->namespaceMap, $this->namespaceMap,
$this->classMap $this->classMap,
) = array_pop($this->contextStack); ) = array_pop($this->contextStack);
} }
} }

View file

@ -109,10 +109,17 @@ class Service
*/ */
public function parse($input, ?string $contextUri = null, ?string &$rootElementName = null) public function parse($input, ?string $contextUri = null, ?string &$rootElementName = null)
{ {
if (is_resource($input)) { if (!is_string($input)) {
// Unfortunately the XMLReader doesn't support streams. When it // Unfortunately the XMLReader doesn't support streams. When it
// does, we can optimize this. // does, we can optimize this.
$input = (string) stream_get_contents($input); if (is_resource($input)) {
$input = (string) stream_get_contents($input);
} else {
// Input is not a string and not a resource.
// Therefore, it has to be a closed resource.
// Effectively empty input has been passed in.
$input = '';
}
} }
// If input is empty, then it's safe to throw an exception // If input is empty, then it's safe to throw an exception
@ -153,10 +160,17 @@ class Service
*/ */
public function expect($rootElementName, $input, ?string $contextUri = null) public function expect($rootElementName, $input, ?string $contextUri = null)
{ {
if (is_resource($input)) { if (!is_string($input)) {
// Unfortunately the XMLReader doesn't support streams. When it // Unfortunately the XMLReader doesn't support streams. When it
// does, we can optimize this. // does, we can optimize this.
$input = (string) stream_get_contents($input); if (is_resource($input)) {
$input = (string) stream_get_contents($input);
} else {
// Input is not a string and not a resource.
// Therefore, it has to be a closed resource.
// Effectively empty input has been passed in.
$input = '';
}
} }
// If input is empty, then it's safe to throw an exception // If input is empty, then it's safe to throw an exception

View file

@ -16,5 +16,5 @@ class Version
/** /**
* Full version number. * Full version number.
*/ */
public const VERSION = '4.0.5'; public const VERSION = '4.0.6';
} }

View file

@ -236,7 +236,7 @@ class Writer extends \XMLWriter
list( list(
$namespace, $namespace,
$localName $localName,
) = Service::parseClarkNotation($name); ) = Service::parseClarkNotation($name);
if (array_key_exists($namespace, $this->namespaceMap)) { if (array_key_exists($namespace, $this->namespaceMap)) {

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Using <strong>%SIZE% (%PROC%%)<\/strong> of your <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Using <strong>%SIZE% (%PROC%%)<\/strong> of your <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Выкарыстана <strong>%SIZE% (%PROC%%)<\/strong> з <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Выкарыстана <strong>%SIZE% (%PROC%%)<\/strong> з <strong>%LIMIT%<\/strong>",
"SORT": "Сартаванне", "SORT": "Сартаванне",
"NO_SORT": "Паштовы сервер не падтрымлівае сартаванне", "NO_SORT": "Паштовы сервер не падтрымлівае сартаванне",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Дата па ўзрастанні", "SORT_DATE_ASC": "Дата па ўзрастанні",
"SORT_DATE_DESC": "Дата па спаданні", "SORT_DATE_DESC": "Дата па спаданні",
"SORT_SIZE_ASC": "Памер па ўзрастанні", "SORT_SIZE_ASC": "Памер па ўзрастанні",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Използване на <strong>%SIZE% (%PROC%%)<\/strong> от вашите <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Използване на <strong>%SIZE% (%PROC%%)<\/strong> от вашите <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Používáte <strong>%SIZE% (%PROC%%)<\/strong> z vašich <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Používáte <strong>%SIZE% (%PROC%%)<\/strong> z vašich <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Du bruger <strong>%SIZE% (%PROC%%)<\/strong> af <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Du bruger <strong>%SIZE% (%PROC%%)<\/strong> af <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Sie nutzen <strong>%SIZE% (%PROC%%)<\/strong> von <strong>%LIMIT%<\/strong>.", "QUOTA_SIZE": "Sie nutzen <strong>%SIZE% (%PROC%%)<\/strong> von <strong>%LIMIT%<\/strong>.",
"SORT": "Sortieren", "SORT": "Sortieren",
"NO_SORT": "Ihr Mailserver unterstützt das Sortieren von E-Mails nicht", "NO_SORT": "Ihr Mailserver unterstützt das Sortieren von E-Mails nicht",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Datum aufsteigend", "SORT_DATE_ASC": "Datum aufsteigend",
"SORT_DATE_DESC": "Datum absteigend", "SORT_DATE_DESC": "Datum absteigend",
"SORT_SIZE_ASC": "Größe aufsteigend", "SORT_SIZE_ASC": "Größe aufsteigend",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Χρησιμοποιούνται <strong>%SIZE% (%PROC%%)<\/strong> από <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Χρησιμοποιούνται <strong>%SIZE% (%PROC%%)<\/strong> από <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Using <strong>%SIZE% (%PROC%%)<\/strong> of your <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Using <strong>%SIZE% (%PROC%%)<\/strong> of your <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Usando <strong>%SIZE% (%PROC%%)<\/strong> de sus <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Usando <strong>%SIZE% (%PROC%%)<\/strong> de sus <strong>%LIMIT%<\/strong>",
"SORT": "Ordenar", "SORT": "Ordenar",
"NO_SORT": "El servidor no soporta clasificación", "NO_SORT": "El servidor no soporta clasificación",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Fecha ascendente", "SORT_DATE_ASC": "Fecha ascendente",
"SORT_DATE_DESC": "Fecha descendente", "SORT_DATE_DESC": "Fecha descendente",
"SORT_SIZE_ASC": "Tamaño ascendente", "SORT_SIZE_ASC": "Tamaño ascendente",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Kasutad <strong>%SIZE% (%PROC%%)<\/strong> lubatud kettamahust <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Kasutad <strong>%SIZE% (%PROC%%)<\/strong> lubatud kettamahust <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "<strong>%SIZE% (%PROC%%)<\/strong> erabiliz zure <strong>%LIMIT%<\/strong>-tik", "QUOTA_SIZE": "<strong>%SIZE% (%PROC%%)<\/strong> erabiliz zure <strong>%LIMIT%<\/strong>-tik",
"SORT": "Ordenatu", "SORT": "Ordenatu",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Data goraka", "SORT_DATE_ASC": "Data goraka",
"SORT_DATE_DESC": "Data beheraka", "SORT_DATE_DESC": "Data beheraka",
"SORT_SIZE_ASC": "Tamaina goraka", "SORT_SIZE_ASC": "Tamaina goraka",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "استفاده <strong>%SIZE% (%PROC%%)<\/strong> از<strong>%LIMIT%<\/strong> برای شما", "QUOTA_SIZE": "استفاده <strong>%SIZE% (%PROC%%)<\/strong> از<strong>%LIMIT%<\/strong> برای شما",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Käytät <strong>%SIZE% (%PROC%%)<\/strong> sinulle varatusta <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Käytät <strong>%SIZE% (%PROC%%)<\/strong> sinulle varatusta <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Vous utilisez <strong>%SIZE% (%PROC%%)<\/strong> sur <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Vous utilisez <strong>%SIZE% (%PROC%%)<\/strong> sur <strong>%LIMIT%<\/strong>",
"SORT": "Trier", "SORT": "Trier",
"NO_SORT": "Le serveur de messagerie ne prend pas en charge le tri", "NO_SORT": "Le serveur de messagerie ne prend pas en charge le tri",
"SORT_ARRIVAL_ASC": "Arrivée ascendante",
"SORT_ARRIVAL_DESC": "Arrivée décroissante",
"SORT_DATE_ASC": "Date ascendante", "SORT_DATE_ASC": "Date ascendante",
"SORT_DATE_DESC": "Date décroissante", "SORT_DATE_DESC": "Date décroissante",
"SORT_SIZE_ASC": "Taille ascendante", "SORT_SIZE_ASC": "Taille ascendante",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Felhasználva <strong>%SIZE% (%PROC%%)<\/strong> ebből: <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Felhasználva <strong>%SIZE% (%PROC%%)<\/strong> ebből: <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Menggunakan <strong>%SIZE% (%PROC%%)<\/strong> dari <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Menggunakan <strong>%SIZE% (%PROC%%)<\/strong> dari <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Notar <strong>%SIZE% (%PROC%%)<\/strong> af <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Notar <strong>%SIZE% (%PROC%%)<\/strong> af <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Stai usando <strong>%SIZE% (%PROC%%)<\/strong> su <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Stai usando <strong>%SIZE% (%PROC%%)<\/strong> su <strong>%LIMIT%<\/strong>",
"SORT": "Riordina", "SORT": "Riordina",
"NO_SORT": "Il mail server non supporta l'ordinamento", "NO_SORT": "Il mail server non supporta l'ordinamento",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Data crescente", "SORT_DATE_ASC": "Data crescente",
"SORT_DATE_DESC": "Data decrescente", "SORT_DATE_DESC": "Data decrescente",
"SORT_SIZE_ASC": "Dimensione crescente", "SORT_SIZE_ASC": "Dimensione crescente",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "<strong>%SIZE% (%PROC%%)<\/strong>\/<strong>%LIMIT%<\/strong>を使用中", "QUOTA_SIZE": "<strong>%SIZE% (%PROC%%)<\/strong>\/<strong>%LIMIT%<\/strong>を使用中",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "日付で昇順", "SORT_DATE_ASC": "日付で昇順",
"SORT_DATE_DESC": "日付で降順", "SORT_DATE_DESC": "日付で降順",
"SORT_SIZE_ASC": "サイズで昇順", "SORT_SIZE_ASC": "サイズで昇順",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "총 <strong>%LIMIT%<\/strong> 중 <strong>%PROC%%<\/strong>을(를) 사용 중입니다.", "QUOTA_SIZE": "총 <strong>%LIMIT%<\/strong> 중 <strong>%PROC%%<\/strong>을(를) 사용 중입니다.",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Naudojate <strong>%SIZE% (%PROC%%)<\/strong> iš savo turimų <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Naudojate <strong>%SIZE% (%PROC%%)<\/strong> iš savo turimų <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Izmantots <strong>%SIZE% (%PROC%%)<\/strong> no <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Izmantots <strong>%SIZE% (%PROC%%)<\/strong> no <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Bruker <strong> %SIZE% ( %PROC%% ) <\/strong> av <strong> %LIMIT% <\/strong>", "QUOTA_SIZE": "Bruker <strong> %SIZE% ( %PROC%% ) <\/strong> av <strong> %LIMIT% <\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Dato, stigende", "SORT_DATE_ASC": "Dato, stigende",
"SORT_DATE_DESC": "Dato, synkende", "SORT_DATE_DESC": "Dato, synkende",
"SORT_SIZE_ASC": "Størrelse, stigende", "SORT_SIZE_ASC": "Størrelse, stigende",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "U gebruikt <strong>%SIZE% (%PROC%%)<\/strong> van <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "U gebruikt <strong>%SIZE% (%PROC%%)<\/strong> van <strong>%LIMIT%<\/strong>",
"SORT": "Sorteren", "SORT": "Sorteren",
"NO_SORT": "Mail server ondersteund sorteren niet", "NO_SORT": "Mail server ondersteund sorteren niet",
"SORT_ARRIVAL_ASC": "Ontvangen oplopend",
"SORT_ARRIVAL_DESC": "Ontvangen aflopend",
"SORT_DATE_ASC": "Datum oplopend", "SORT_DATE_ASC": "Datum oplopend",
"SORT_DATE_DESC": "Datum aflopend", "SORT_DATE_DESC": "Datum aflopend",
"SORT_SIZE_ASC": "Grootte oplopend", "SORT_SIZE_ASC": "Grootte oplopend",

View file

@ -136,11 +136,11 @@
"SECURE_OPTION_NONE": "Bez zabezpieczeń", "SECURE_OPTION_NONE": "Bez zabezpieczeń",
"LABEL_ALLOW_SIEVE_SCRIPTS": "Zezwól na skrypty sieve", "LABEL_ALLOW_SIEVE_SCRIPTS": "Zezwól na skrypty sieve",
"LABEL_USE_SHORT_LOGIN": "Użyj krótkiego loginu", "LABEL_USE_SHORT_LOGIN": "Użyj krótkiego loginu",
"LABEL_LOWERCASE_LOGIN": "Lowercase login (case-insensitive)", "LABEL_LOWERCASE_LOGIN": "Logowanie małymi literami (bez uwzględniania ich wielkości)",
"LABEL_USE_AUTH": "Użyj uwierzytelnienia", "LABEL_USE_AUTH": "Użyj uwierzytelnienia",
"LABEL_SET_SENDER": "Użyj loginu jako nadawcy", "LABEL_SET_SENDER": "Użyj loginu jako nadawcy",
"LABEL_AUTH_PLAIN_LINE": "Wymuś logowanie pojedynczą komendą AUTH PLAIN", "LABEL_AUTH_PLAIN_LINE": "Wymuś logowanie pojedynczą komendą AUTH PLAIN",
"LABEL_USE_PHP_MAIL": "Użyj funkcji php 'mail()'", "LABEL_USE_PHP_MAIL": "Użyj funkcji PHP 'mail()'",
"BUTTON_WHITE_LIST": "Biała lista", "BUTTON_WHITE_LIST": "Biała lista",
"NEW_DOMAIN_DESC": "Konfiguracja tej domeny pozwala na pracę z adresami e-mail: <strong>%NAME%<\/strong>", "NEW_DOMAIN_DESC": "Konfiguracja tej domeny pozwala na pracę z adresami e-mail: <strong>%NAME%<\/strong>",
"WHITE_LIST_ALERT": "Lista użytkowników domeny, którzy mogą uzyskać dostęp z tego webmaila.\nUżyj spacji do rozdzielenia.\n", "WHITE_LIST_ALERT": "Lista użytkowników domeny, którzy mogą uzyskać dostęp z tego webmaila.\nUżyj spacji do rozdzielenia.\n",

View file

@ -89,18 +89,18 @@
"ARCHIVE_NAME": "Archiwum" "ARCHIVE_NAME": "Archiwum"
}, },
"IMAP": { "IMAP": {
"ACL_RIGHTS": "Rights", "ACL_RIGHTS": "Uprawnienia",
"ACL_A": "Administer", "ACL_A": "Administracja",
"ACL_E": "Expunge", "ACL_E": "Usuń",
"ACL_I": "Insert messages", "ACL_I": "Wstaw wiadomości",
"ACL_K": "Create sub-folders", "ACL_K": "Utwórz podfoldery",
"ACL_L": "Show messages", "ACL_L": "Pokaż wiadomości",
"ACL_P": "Send mail", "ACL_P": "Wyślij mail",
"ACL_R": "Select folder", "ACL_R": "Wybierz folder",
"ACL_S": "\\SEEN tag", "ACL_S": "\\Tag przeczytane",
"ACL_T": "\\DELETED tag", "ACL_T": "\\Tag usunięte",
"ACL_W": "Other tags", "ACL_W": "Inne tagi",
"ACL_X": "Delete\/Rename folder" "ACL_X": "Usuń\/Zmień nazwę folder(-u)"
}, },
"MESSAGE_LIST": { "MESSAGE_LIST": {
"BUTTON_RELOAD": "Odśwież listę wiadomości", "BUTTON_RELOAD": "Odśwież listę wiadomości",
@ -130,6 +130,8 @@
"QUOTA_SIZE": "Wykorzystano <strong>%SIZE% (%PROC%%)<\/strong> z <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Wykorzystano <strong>%SIZE% (%PROC%%)<\/strong> z <strong>%LIMIT%<\/strong>",
"SORT": "Sortowanie", "SORT": "Sortowanie",
"NO_SORT": "Serwer pocztowy nie wspiera sortowania", "NO_SORT": "Serwer pocztowy nie wspiera sortowania",
"SORT_ARRIVAL_ASC": "Odebrano rosnąco",
"SORT_ARRIVAL_DESC": "Odebrano malejąco",
"SORT_DATE_ASC": "Data rosnąco", "SORT_DATE_ASC": "Data rosnąco",
"SORT_DATE_DESC": "Data malejąco", "SORT_DATE_DESC": "Data malejąco",
"SORT_SIZE_ASC": "Wielkość rosnąco", "SORT_SIZE_ASC": "Wielkość rosnąco",
@ -219,9 +221,9 @@
"DISPLAY_NAME": "Nazwa wyświetlana", "DISPLAY_NAME": "Nazwa wyświetlana",
"LAST_NAME": "Nazwisko", "LAST_NAME": "Nazwisko",
"FIRST_NAME": "Imię", "FIRST_NAME": "Imię",
"MIDDLE_NAME": "Middle name", "MIDDLE_NAME": "Drugie imię",
"NAME_PREFIX": "Name prefix", "NAME_PREFIX": "Przedrostek imienia",
"NAME_SUFFIX": "Name suffix", "NAME_SUFFIX": "Przyrostek imienia",
"NICK_NAME": "Pseudonim", "NICK_NAME": "Pseudonim",
"LABEL_READ_ONLY": "Tylko do odczytu", "LABEL_READ_ONLY": "Tylko do odczytu",
"ADD_MENU_LABEL": "Dodaj", "ADD_MENU_LABEL": "Dodaj",
@ -537,7 +539,7 @@
"TO_MANY_FOLDERS_DESC_2": "Pokazaliśmy tylko część z nich aby uniknąć problemów z wydajnością.", "TO_MANY_FOLDERS_DESC_2": "Pokazaliśmy tylko część z nich aby uniknąć problemów z wydajnością.",
"HELP_DELETE_FOLDER": "Usuń folder", "HELP_DELETE_FOLDER": "Usuń folder",
"HELP_SHOW_HIDE_FOLDER": "Pokaż\/ukryj folder", "HELP_SHOW_HIDE_FOLDER": "Pokaż\/ukryj folder",
"HELP_CHECK_FOR_NEW_MESSAGES": "Sprawdzaj nowych wiadomości", "HELP_CHECK_FOR_NEW_MESSAGES": "Sprawdź czy są nowe wiadomości",
"HIDE_UNSUBSCRIBED": "Ukryj niesubskrybowane foldery", "HIDE_UNSUBSCRIBED": "Ukryj niesubskrybowane foldery",
"UNHIDE_KOLAB_FOLDERS": "Pokaż foldery Kolab", "UNHIDE_KOLAB_FOLDERS": "Pokaż foldery Kolab",
"TYPE_CALENDAR": "Kalendarz", "TYPE_CALENDAR": "Kalendarz",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Utilizando <strong>%SIZE% (%PROC%%)<\/strong> de <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Utilizando <strong>%SIZE% (%PROC%%)<\/strong> de <strong>%LIMIT%<\/strong>",
"SORT": "Sort", "SORT": "Sort",
"NO_SORT": "Mail server does not support sorting", "NO_SORT": "Mail server does not support sorting",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Date ascending", "SORT_DATE_ASC": "Date ascending",
"SORT_DATE_DESC": "Date descending", "SORT_DATE_DESC": "Date descending",
"SORT_SIZE_ASC": "Size ascending", "SORT_SIZE_ASC": "Size ascending",
@ -219,9 +221,9 @@
"DISPLAY_NAME": "Nome de exibição", "DISPLAY_NAME": "Nome de exibição",
"LAST_NAME": "Sobrenome", "LAST_NAME": "Sobrenome",
"FIRST_NAME": "Primeiro nome", "FIRST_NAME": "Primeiro nome",
"MIDDLE_NAME": "Middle name", "MIDDLE_NAME": "Nomes adicionais",
"NAME_PREFIX": "Name prefix", "NAME_PREFIX": "Prefixo do nome",
"NAME_SUFFIX": "Name suffix", "NAME_SUFFIX": "Sufixo do nome",
"NICK_NAME": "Apelido", "NICK_NAME": "Apelido",
"LABEL_READ_ONLY": "Somente Leitura", "LABEL_READ_ONLY": "Somente Leitura",
"ADD_MENU_LABEL": "Adicionar", "ADD_MENU_LABEL": "Adicionar",

View file

@ -130,6 +130,8 @@
"QUOTA_SIZE": "Está a usar <strong>%SIZE% (%PROC%%)<\/strong> dos <strong>%LIMIT%<\/strong>", "QUOTA_SIZE": "Está a usar <strong>%SIZE% (%PROC%%)<\/strong> dos <strong>%LIMIT%<\/strong>",
"SORT": "Ordenar", "SORT": "Ordenar",
"NO_SORT": "O servidor de correio não suporta ordenação", "NO_SORT": "O servidor de correio não suporta ordenação",
"SORT_ARRIVAL_ASC": "Arrived ascending",
"SORT_ARRIVAL_DESC": "Arrived descending",
"SORT_DATE_ASC": "Data ascendente", "SORT_DATE_ASC": "Data ascendente",
"SORT_DATE_DESC": "Data descendente", "SORT_DATE_DESC": "Data descendente",
"SORT_SIZE_ASC": "Tamanho ascendente", "SORT_SIZE_ASC": "Tamanho ascendente",

Some files were not shown because too many files have changed in this diff Show more