Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SOPE/NGCards/iCalRepeatableEntityObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
- (BOOL)hasRecurrenceRules;
- (NSArray *)recurrenceRules;
- (NSArray *)recurrenceRulesWithTimeZone: (id) timezone;
- (BOOL) removeDuplicateRecurrenceRules;

- (void) removeAllRecurrenceDates;
- (void) addToRecurrenceDates: (NSCalendarDate *) _rdate;
Expand Down
35 changes: 35 additions & 0 deletions SOPE/NGCards/iCalRepeatableEntityObject.m
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,41 @@ - (NSArray *) recurrenceRulesWithTimeZone: (id) timezone
return [self rules: rules withTimeZone: timezone];
}

- (BOOL) removeDuplicateRecurrenceRules
{
NSArray *rules;
NSMutableArray *duplicateRules, *ruleStrings;
NSEnumerator *allRules;
iCalRecurrenceRule *currentRule;
NSString *currentRuleString;

rules = [self recurrenceRules];
if ([rules count] < 2)
return NO;

duplicateRules = [NSMutableArray array];
ruleStrings = [NSMutableArray array];

allRules = [rules objectEnumerator];
while ((currentRule = [allRules nextObject]))
{
currentRuleString = [currentRule versitString];
if ([ruleStrings containsObject: currentRuleString])
[duplicateRules addObject: currentRule];
else
[ruleStrings addObject: currentRuleString];
}

allRules = [duplicateRules objectEnumerator];
while ((currentRule = [allRules nextObject]))
{
[currentRule setParent: nil];
[children removeObjectIdenticalTo: currentRule];
}

return ([duplicateRules count] > 0);
}

- (void) removeAllRecurrenceDates
{
[self removeChildren: [self childrenWithTag: @"rdate"]];
Expand Down
8 changes: 8 additions & 0 deletions SoObjects/Appointments/SOGoAppointmentObject.m
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@

#import "SOGoAppointmentObject.h"

@interface SOGoCalendarComponent (DuplicateRecurrenceRules)

- (BOOL) _removeDuplicateRecurrenceRulesFromCalendar: (iCalCalendar *) calendar;

@end

@implementation SOGoAppointmentObject

- (NSString *) componentTag
Expand Down Expand Up @@ -2237,6 +2243,8 @@ - (NSException *) updateContentWithCalendar: (iCalCalendar *) calendar
[self adjustClassificationInRequestCalendar: calendar];
[self _adjustPartStatInRequestCalendar: calendar];
}

[self _removeDuplicateRecurrenceRulesFromCalendar: calendar];

//
// We first check if it's a new event
Expand Down
69 changes: 67 additions & 2 deletions SoObjects/Appointments/SOGoCalendarComponent.m
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,57 @@ - (void) _filterComponent: (iCalEntityObject *) component
[component setUid: uid];
}

- (BOOL) _removeDuplicateRecurrenceRulesFromCalendar: (iCalCalendar *) calendar
{
iCalRepeatableEntityObject *currentComponent;
NSArray *allComponents;
BOOL modified;
int i;

allComponents = [calendar childrenWithTag: [self componentTag]];
modified = NO;

for (i = 0; i < [allComponents count]; i++)
{
currentComponent = (iCalRepeatableEntityObject *) [allComponents objectAtIndex: i];
modified = [currentComponent removeDuplicateRecurrenceRules] || modified;
}

return modified;
}

- (NSString *) _contentByRemovingDuplicateRecurrenceRulesFromString: (NSString *) iCalString
{
iCalCalendar *calendar;
NSRange firstRange, secondRange;
BOOL modified;

if (![iCalString length])
return iCalString;

/* Avoid parsing the common single-RRULE case; duplicates need two markers. */
firstRange = [iCalString rangeOfString: @"RRULE"
options: NSCaseInsensitiveSearch];
if (firstRange.location == NSNotFound)
return iCalString;

secondRange
= [iCalString rangeOfString: @"RRULE"
options: NSCaseInsensitiveSearch
range: NSMakeRange (NSMaxRange (firstRange),
[iCalString length] - NSMaxRange (firstRange))];
if (secondRange.location == NSNotFound)
return iCalString;

calendar = [iCalCalendar parseSingleFromSource: iCalString];
modified = [self _removeDuplicateRecurrenceRulesFromCalendar: calendar];

if (modified)
iCalString = [calendar versitString];

return iCalString;
}

- (NSString *) secureContentAsString
{
iCalRepeatableEntityObject *tmpComponent;
Expand All @@ -271,13 +322,14 @@ - (NSString *) secureContentAsString
|| [[self ownerInContext: context] isEqualToString: [[context activeUser] login]]
|| ![sm validatePermission: SOGoCalendarPerm_ViewAllComponent
onObject: self inContext: context])
iCalString = content;
iCalString = [self _contentByRemovingDuplicateRecurrenceRulesFromString: content];
else if (![sm validatePermission: SOGoCalendarPerm_ViewDAndT
onObject: self inContext: context])
{
tmpCalendar = [[self calendar: NO secure: NO] mutableCopy];

// We filter all components, in case we have RECURRENCE-ID
[self _removeDuplicateRecurrenceRulesFromCalendar: tmpCalendar];
allComponents = [tmpCalendar childrenWithTag: [self componentTag]];

for (i = 0; i < [allComponents count]; i++)
Expand Down Expand Up @@ -426,10 +478,11 @@ - (NSString *) _secureContentWithoutAlarms
{
iCalCalendar *calendar;
NSArray *allComponents;
iCalEntityObject *currentComponent;
iCalRepeatableEntityObject *currentComponent;
NSUInteger count, max;

calendar = [self calendar: NO secure: YES];
[self _removeDuplicateRecurrenceRulesFromCalendar: calendar];
allComponents = [calendar childrenWithTag: [self componentTag]];
max = [allComponents count];
for (count = 0; count < max; count++)
Expand Down Expand Up @@ -672,6 +725,8 @@ - (void) updateComponent: (iCalRepeatableEntityObject *) newObject
{
NSString *newUid;

[newObject removeDuplicateRecurrenceRules];

if (!isNew
&& [newObject isRecurrent])
// We update an repeating event -- update exception dates
Expand Down Expand Up @@ -706,6 +761,16 @@ - (NSException *) saveComponent: (iCalRepeatableEntityObject *) newObject
return [self saveCalendar: [newObject parent]];
}

- (NSException *) saveComponent: (id) theComponent
baseVersion: (unsigned int) newVersion
{
if ([theComponent isKindOfClass: [iCalCalendar class]])
[self _removeDuplicateRecurrenceRulesFromCalendar: theComponent];

return [super saveComponent: theComponent
baseVersion: newVersion];
}

- (NSException *) saveComponent: (iCalRepeatableEntityObject *) newEvent
force: (BOOL) forceSave
{
Expand Down
1 change: 1 addition & 0 deletions Tests/Unit/GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ $(TEST_TOOL)_OBJC_FILES += \
TestVersit.m \
TestiCalTimeZonePeriod.m \
TestiCalRecurrenceCalculator.m \
TestiCalRepeatableEntityObject.m \
\
TestSBJsonParser.m \
\
Expand Down
62 changes: 62 additions & 0 deletions Tests/Unit/TestiCalRepeatableEntityObject.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* TestiCalRepeatableEntityObject.m - this file is part of SOGo
*
* This file is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This file is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
* Boston, MA 02111-1307, USA.
*/

#import <NGCards/iCalCalendar.h>
#import <NGCards/iCalRepeatableEntityObject.h>

#import "SOGoTest.h"

@interface TestiCalRepeatableEntityObject : SOGoTest
@end

@implementation TestiCalRepeatableEntityObject

- (void) test_removeDuplicateRecurrenceRules
{
iCalCalendar *calendar;
iCalRepeatableEntityObject *event;
NSArray *rules;
NSString *versit;

versit = @"BEGIN:VCALENDAR\r\n"
@"VERSION:2.0\r\n"
@"BEGIN:VEVENT\r\n"
@"UID:duplicate-rrule\r\n"
@"DTSTART:20260211T123000Z\r\n"
@"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE\r\n"
@"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE\r\n"
@"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR\r\n"
@"END:VEVENT\r\n"
@"END:VCALENDAR";
calendar = [iCalCalendar parseSingleFromSource: versit];
event = (iCalRepeatableEntityObject *) [calendar firstChildWithTag: @"vevent"];

test([event removeDuplicateRecurrenceRules]);

rules = [event recurrenceRules];
testWithMessage([rules count] == 2,
([NSString stringWithFormat: @"expected 2 recurrence rules, got %lu",
(unsigned long) [rules count]]));
testEquals([[rules objectAtIndex: 0] versitString],
@"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
testEquals([[rules objectAtIndex: 1] versitString],
@"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR");
failIf([event removeDuplicateRecurrenceRules]);
}

@end
44 changes: 43 additions & 1 deletion Tests/spec/CalDAVPropertiesSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,46 @@ END:VCALENDAR`
.withContext(`Returned vCalendar matches ${filename}`)
.toBe(true)
})
})

it("calendar-multiget removes exact duplicate recurrence rules", async function() {
const filename = `duplicate-rrule.ics`
const event = `BEGIN:VCALENDAR
PRODID:-//Inverse//Event Generator//EN
VERSION:2.0
BEGIN:VEVENT
UID:duplicate-rrule
SUMMARY:Duplicate recurrence rule
DTSTART:20260211T123000Z
DTEND:20260211T140000Z
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR
END:VEVENT
END:VCALENDAR`

let response = await webdav.createCalendarObject(resource, filename, event)
expect(response.status).toBe(201)

response = await webdav.calendarMultiGet(resource, filename)
expect(response.length)
.withContext(`Number of results from calendar-multiget`)
.toBe(1)

const calendarData = response[0].props.calendarData.replace(/\r\n/g, '\n')
const wednesdayRules = calendarData.match(/^RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE$/gm) || []
const fridayRules = calendarData.match(/^RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR$/gm) || []

expect(wednesdayRules.length)
.withContext(`Duplicated recurrence rule is returned once`)
.toBe(1)
expect(fridayRules.length)
.withContext(`Different recurrence rule is preserved`)
.toBe(1)

const dirtySerializedLength = Buffer.byteLength(`${event.replace(/\n/g, '\r\n')}\r\n`, 'utf8')
const [objectProperties] = await webdav.propfindWebdav(resource + filename, ['getcontentlength'])
expect(Number(objectProperties.props.getcontentlength))
.withContext(`Persisted calendar object is normalized on save`)
.toBeLessThan(dirtySerializedLength)
})
})