Skip to content

Commit cb51a36

Browse files
Merge branch 'stable33' into backport/61838/stable33
2 parents 3279437 + 308e3fa commit cb51a36

303 files changed

Lines changed: 2219 additions & 1797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1541,7 +1541,7 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca
15411541
if ($found !== false) {
15421542
// the object existed previously but has been deleted
15431543
// remove the trashbin entry and continue as if it was a new object
1544-
$this->deleteCalendarObject($calendarId, $found['uri']);
1544+
$this->deleteCalendarObject($calendarId, $found['uri'], $calendarType, true);
15451545
}
15461546

15471547
$query = $this->db->getQueryBuilder();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mount } from '@vue/test-utils'
7+
import { afterEach, describe, expect, it, vi } from 'vitest'
8+
import AbsenceForm from './AbsenceForm.vue'
9+
10+
let davAbsence
11+
vi.mock('@nextcloud/initial-state', () => ({
12+
loadState(app, key, fallback) {
13+
if (app === 'dav' && key === 'absence' && davAbsence !== undefined) {
14+
return davAbsence
15+
}
16+
if (fallback !== undefined) {
17+
return fallback
18+
}
19+
20+
console.error('Unexpected loadState call without fallback', { app, key })
21+
throw new Error()
22+
},
23+
}))
24+
25+
afterEach(() => {
26+
vi.unstubAllEnvs()
27+
davAbsence = undefined
28+
vi.resetModules()
29+
})
30+
31+
function getInputs(wrapper) {
32+
const lables = wrapper.findAll('label')
33+
34+
const firstDayLabel = lables.find((l) => l.text() === 'First day')
35+
const firstDayInput = wrapper.get(`#${firstDayLabel.attributes('for')}`)
36+
37+
const lastDayLabel = lables.find((l) => l.text() === 'Last day (inclusive)')
38+
const lastDayInput = wrapper.get(`#${lastDayLabel.attributes('for')}`)
39+
40+
return { firstDayInput, lastDayInput }
41+
}
42+
43+
describe('AbsenceForm', () => {
44+
it('displays default state when browser timezone is set', async () => {
45+
vi.setSystemTime(new Date(2026, 5, 29, 5, 0))
46+
vi.stubEnv('TZ', 'US/Pacific')
47+
48+
const wrapper = mount(AbsenceForm)
49+
50+
const { firstDayInput } = getInputs(wrapper)
51+
expect(firstDayInput.element.value).toBe('2026-06-29')
52+
})
53+
54+
it('displays state when browser timezone is set', async () => {
55+
vi.stubEnv('TZ', 'US/Pacific')
56+
davAbsence = {
57+
firstDay: '2026-06-29',
58+
lastDay: '2026-06-30',
59+
}
60+
61+
const wrapper = mount(AbsenceForm)
62+
63+
const { firstDayInput, lastDayInput } = getInputs(wrapper)
64+
expect(firstDayInput.element.value).toBe('2026-06-29')
65+
expect(lastDayInput.element.value).toBe('2026-06-30')
66+
})
67+
})

apps/dav/src/components/AbsenceForm.vue

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
7272
import { logger } from '../service/logger.ts'
7373
import { formatDateAsYMD } from '../utils/date.ts'
7474
75+
/**
76+
* Adjusts a date so `NcDateTimePickerNative` shows the same date
77+
* instead of shifting it to the browsers timezone.
78+
*
79+
* @param {Date} date - e.g., new Date("1987-12-01")
80+
* @return {Date}
81+
*/
82+
function inputAdjustDate(date) {
83+
// e.g., date === Mon Nov 30 1987 16:00:00 GMT-0800 (Pacific Standard Time)
84+
const timezoneOffsetMilliseconds = date.getTimezoneOffset() * 60 * 1000
85+
// e.g., Tue Dec 01 1987 00:00:00 GMT-0800 (Pacific Standard Time)
86+
const adjustedDate = new Date(date.getTime() + timezoneOffsetMilliseconds)
87+
// `NcDateTimePickerNative` will display this as 12/01/1987
88+
return adjustedDate
89+
}
90+
7591
export default {
7692
name: 'AbsenceForm',
7793
components: {
@@ -88,12 +104,15 @@ export default {
88104
89105
data() {
90106
const { firstDay, lastDay, status, message, replacementUserId, replacementUserDisplayName } = loadState('dav', 'absence', {})
107+
const firstDayDate = firstDay ? new Date(firstDay) : new Date()
108+
const firstDayInputAdjusted = inputAdjustDate(firstDayDate)
109+
const lastDayInputAdjusted = lastDay ? inputAdjustDate(new Date(lastDay)) : null
91110
return {
92111
loading: false,
93112
status: status ?? '',
94113
message: message ?? '',
95-
firstDay: firstDay ? new Date(firstDay) : new Date(),
96-
lastDay: lastDay ? new Date(lastDay) : null,
114+
firstDay: firstDayInputAdjusted,
115+
lastDay: lastDayInputAdjusted,
97116
replacementUserId,
98117
replacementUser: replacementUserId ? { user: replacementUserId, displayName: replacementUserDisplayName } : null,
99118
searchLoading: false,

apps/dav/tests/unit/CalDAV/CalDavBackendTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,46 @@ public function testMultipleCalendarObjectsWithSameUID(): void {
294294
$this->backend->createCalendarObject($calendarId, $uri1, $calData);
295295
}
296296

297+
public function testCreateCalendarObjectWithSameUidAsObjectInTrashbin(): void {
298+
$calendarId = $this->createTestCalendar();
299+
300+
$calData = <<<'EOD'
301+
BEGIN:VCALENDAR
302+
VERSION:2.0
303+
PRODID:ownCloud Calendar
304+
BEGIN:VEVENT
305+
CREATED;VALUE=DATE-TIME:20130910T125139Z
306+
UID:47d15e3ec8
307+
LAST-MODIFIED;VALUE=DATE-TIME:20130910T125139Z
308+
DTSTAMP;VALUE=DATE-TIME:20130910T125139Z
309+
SUMMARY:Test Event
310+
DTSTART;VALUE=DATE-TIME:20130912T130000Z
311+
DTEND;VALUE=DATE-TIME:20130912T140000Z
312+
CLASS:PUBLIC
313+
END:VEVENT
314+
END:VCALENDAR
315+
EOD;
316+
317+
$uri = static::getUniqueID('event') . '.ics';
318+
$this->backend->createCalendarObject($calendarId, $uri, $calData);
319+
320+
// Soft-delete the object so it is moved to the trashbin but keeps its UID
321+
$this->backend->deleteCalendarObject($calendarId, $uri);
322+
$trashbinUri = str_replace('.ics', '-deleted.ics', $uri);
323+
$trashedObject = $this->backend->getCalendarObject($calendarId, $trashbinUri);
324+
$this->assertNotNull($trashedObject);
325+
326+
// Recreating an object with the same UID must purge the trashbin entry
327+
// instead of violating the unique index on (calendarid, calendartype, uid)
328+
$newUri = static::getUniqueID('event') . '.ics';
329+
$this->backend->createCalendarObject($calendarId, $newUri, $calData);
330+
331+
$this->assertNull($this->backend->getCalendarObject($calendarId, $trashbinUri));
332+
$newObject = $this->backend->getCalendarObject($calendarId, $newUri);
333+
$this->assertNotNull($newObject);
334+
$this->assertEquals($calData, $newObject['calendardata']);
335+
}
336+
297337
public function testMultiCalendarObjects(): void {
298338
$calendarId = $this->createTestCalendar();
299339

apps/files/l10n/uk.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ OC.L10N.register(
178178
"_{count} selected_::_{count} selected_" : ["Вибрано {count}","Вибрано {count}","Вибрано {count} ","Вибрано {count} "],
179179
"Views" : "Подання",
180180
"Search everywhere …" : "Шукайте скрізь ...",
181-
"Search here …" : "Шукайте тут ...",
181+
"Search here …" : "Швидкий фільтр ...",
182182
"Search scope options" : "Визначити місце пошуку",
183183
"Search here" : "Швидкий пошук",
184184
"Owner" : "Власник",

apps/files/l10n/uk.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@
176176
"_{count} selected_::_{count} selected_" : ["Вибрано {count}","Вибрано {count}","Вибрано {count} ","Вибрано {count} "],
177177
"Views" : "Подання",
178178
"Search everywhere …" : "Шукайте скрізь ...",
179-
"Search here …" : "Шукайте тут ...",
179+
"Search here …" : "Швидкий фільтр ...",
180180
"Search scope options" : "Визначити місце пошуку",
181181
"Search here" : "Швидкий пошук",
182182
"Owner" : "Власник",

apps/files/src/components/FileEntry/FileEntryName.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export default defineComponent({
190190
return
191191
}
192192
193-
let validity = getFilenameValidity(newName)
193+
let validity = getFilenameValidity(newName, false, this.source.type === FileType.Folder)
194194
// Checking if already exists
195195
if (validity === '' && this.checkIfNodeExists(newName)) {
196196
validity = t('files', 'Another entry with the same name already exists.')

apps/files/src/components/NewNodeDialog.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ const props = defineProps({
8989
type: String,
9090
default: t('files', 'Folder name'),
9191
},
92+
93+
/**
94+
* Whether the name is for a folder, which affects the validation of the name. Defaults to false.
95+
*/
96+
isFolder: {
97+
type: Boolean,
98+
default: false,
99+
},
92100
})
93101
94102
const emit = defineEmits<{
@@ -142,7 +150,7 @@ watchEffect(() => {
142150
if (props.otherNames.includes(localDefaultName.value.trim())) {
143151
validity.value = t('files', 'This name is already in use.')
144152
} else {
145-
validity.value = getFilenameValidity(localDefaultName.value.trim())
153+
validity.value = getFilenameValidity(localDefaultName.value.trim(), false, props.isFolder)
146154
}
147155
const input = nameInput.value?.$el.querySelector('input')
148156
if (input) {

apps/files/src/newMenu/newFolder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const entry: NewMenuEntry = {
2929
},
3030

3131
async handler(context: IFolder, content: INode[]) {
32-
const name = await newNodeName(t('files', 'New folder'), content)
32+
const name = await newNodeName(t('files', 'New folder'), content, { isFolder: true })
3333
if (name === null) {
3434
return
3535
}

apps/files/src/newMenu/newTemplatesFolder.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Folder, NewMenuEntry, Node } from '@nextcloud/files'
6+
import type { IFolder, INode, NewMenuEntry } from '@nextcloud/files'
77

88
import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
99
import { getCurrentUser } from '@nextcloud/auth'
@@ -28,7 +28,7 @@ logger.debug('Initial templates folder', { templatesPath })
2828
* @param directory Folder where to create the templates folder
2929
* @param name Name to use or the templates folder
3030
*/
31-
async function initTemplatesFolder(directory: Folder, name: string) {
31+
async function initTemplatesFolder(directory: IFolder, name: string) {
3232
const templatePath = join(directory.path, name)
3333
try {
3434
logger.debug('Initializing the templates directory', { templatePath })
@@ -59,7 +59,7 @@ export const entry: NewMenuEntry = {
5959
displayName: t('files', 'Create templates folder'),
6060
iconSvgInline: PlusSvg,
6161
order: 30,
62-
enabled(context: Folder): boolean {
62+
enabled(context: IFolder): boolean {
6363
// Templates disabled or templates folder already initialized
6464
if (!templatesEnabled || templatesPath) {
6565
return false
@@ -70,8 +70,8 @@ export const entry: NewMenuEntry = {
7070
}
7171
return (context.permissions & Permission.CREATE) !== 0
7272
},
73-
async handler(context: Folder, content: Node[]) {
74-
const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') })
73+
async handler(context: IFolder, content: INode[]) {
74+
const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder'), isFolder: true })
7575

7676
if (name !== null) {
7777
// Create the template folder

0 commit comments

Comments
 (0)