Skip to content
Merged
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
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ check that the generated OpenAPI specs still match the controllers. Every gate
runs even when an earlier one fails, so one invocation gives you the whole
picture. It needs `npm ci` and `composer install` to have run first.

The linters live in their own `vendor-bin/*` installs, and **a `git pull` does
not update them** — only `composer install` (through its `post-install-cmd`) or
`composer bin all install` does. Pull a change that bumps one of them and the
old binary stays behind, which is how psalm ended up crashing on a PHP its
version predates and looking like a broken environment. A gate that crashes
rather than reporting findings is the signature; check what is actually
installed before blaming the toolchain:

```bash
composer bin all install
```

Rules that keep the repo clean over time:

- **Never silence a gate to make it pass.** No `eslint-disable`, no
Expand All @@ -23,7 +35,12 @@ Rules that keep the repo clean over time:
- **`psalm-baseline.xml` is the known backlog, not a dumping ground.** New
findings get fixed in the code. Do not run `psalm --set-baseline` to make
your own errors disappear — that hides them and rewrites 1300 unrelated
entries. Entries only ever leave the baseline by being fixed.
entries. Entries only ever leave the baseline by being fixed. Note that it
counts occurrences *per file and issue type*, so adding one more call of an
already-baselined kind surfaces an error pointing at somebody else's line —
the fix belongs in your new code, not there. Deprecated `IConfig::getAppValue`
is the usual one; new settings in `ConfigService` take `IAppConfig`
(`getValueBool`/`setValueBool`).
- **Do not run `psalm --alter`.** Its suggestions are led by deleting
"unused" methods (this app is DI-driven, so psalm cannot see most call
sites) and by making classes `final` (PHPUnit cannot mock final classes, so
Expand Down
18 changes: 17 additions & 1 deletion l10n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,22 @@ OC.L10N.register(
"Location copied" : "Ort kopiert",
"Maps app" : "Karten-App",
"Notify users who can see this appointment about its creation" : "Benutzer, die diesen Termin sehen können, über die Erstellung benachrichtigen",
"OpenStreetMap" : "OpenStreetMap"
"OpenStreetMap" : "OpenStreetMap",
"Columns" : "Spalten",
"_%n column_::_%n columns_" : ["%n Spalte","%n Spalten"],
"Scheduling rate" : "Einplanungsquote",
"Counted only over closed inquiries where somebody was scheduled" : "Zählt nur geschlossene Anfragen, bei denen jemand eingeplant wurde",
"Show the response summary in the calendar event" : "Antwortübersicht im Kalendereintrag anzeigen",
"Everyone the calendar is shared with sees how many people accepted, without opening the app. Keeping it current means writing to the event after every answer, and Nextcloud reports each of those writes as a calendar change." : "Alle, für die der Kalender freigegeben ist, sehen ohne die App zu öffnen, wie viele zugesagt haben. Dafür wird der Kalendereintrag nach jeder Antwort neu geschrieben, und Nextcloud meldet jeden dieser Schreibvorgänge als Kalenderänderung.",
"Highlights" : "Highlights",
"Most maybe answers" : "Meiste Vielleicht-Antworten",
"Most times scheduled" : "Am häufigsten eingeplant",
"Never" : "Nie",
"Top attendance rate" : "Top Anwesenheitsquote",
"Top acceptance rate" : "Top Zusagequote",
"Top response rate" : "Top Antwortquote",
"Top scheduling rate" : "Top Einplanungsquote",
"Absent more often" : "Öfter nicht dabei",
"Choose highlights" : "Highlights auswählen"
},
"nplurals=2; plural=(n != 1);");
18 changes: 17 additions & 1 deletion l10n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,22 @@
"Location copied" : "Ort kopiert",
"Maps app" : "Karten-App",
"Notify users who can see this appointment about its creation" : "Benutzer, die diesen Termin sehen können, über die Erstellung benachrichtigen",
"OpenStreetMap" : "OpenStreetMap"
"OpenStreetMap" : "OpenStreetMap",
"Columns" : "Spalten",
"_%n column_::_%n columns_" : ["%n Spalte","%n Spalten"],
"Scheduling rate" : "Einplanungsquote",
"Counted only over closed inquiries where somebody was scheduled" : "Zählt nur geschlossene Anfragen, bei denen jemand eingeplant wurde",
"Show the response summary in the calendar event" : "Antwortübersicht im Kalendereintrag anzeigen",
"Everyone the calendar is shared with sees how many people accepted, without opening the app. Keeping it current means writing to the event after every answer, and Nextcloud reports each of those writes as a calendar change." : "Alle, für die der Kalender freigegeben ist, sehen ohne die App zu öffnen, wie viele zugesagt haben. Dafür wird der Kalendereintrag nach jeder Antwort neu geschrieben, und Nextcloud meldet jeden dieser Schreibvorgänge als Kalenderänderung.",
"Highlights" : "Highlights",
"Most maybe answers" : "Meiste Vielleicht-Antworten",
"Most times scheduled" : "Am häufigsten eingeplant",
"Never" : "Nie",
"Top attendance rate" : "Top Anwesenheitsquote",
"Top acceptance rate" : "Top Zusagequote",
"Top response rate" : "Top Antwortquote",
"Top scheduling rate" : "Top Einplanungsquote",
"Absent more often" : "Öfter nicht dabei",
"Choose highlights" : "Highlights auswählen"
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
18 changes: 17 additions & 1 deletion l10n/de_DE.js
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,22 @@ OC.L10N.register(
"Location copied" : "Ort kopiert",
"Maps app" : "Karten-App",
"Notify users who can see this appointment about its creation" : "Benutzer, die diesen Termin sehen können, über die Erstellung benachrichtigen",
"OpenStreetMap" : "OpenStreetMap"
"OpenStreetMap" : "OpenStreetMap",
"Columns" : "Spalten",
"_%n column_::_%n columns_" : ["%n Spalte","%n Spalten"],
"Scheduling rate" : "Einplanungsquote",
"Counted only over closed inquiries where somebody was scheduled" : "Zählt nur geschlossene Anfragen, bei denen jemand eingeplant wurde",
"Show the response summary in the calendar event" : "Antwortübersicht im Kalendereintrag anzeigen",
"Everyone the calendar is shared with sees how many people accepted, without opening the app. Keeping it current means writing to the event after every answer, and Nextcloud reports each of those writes as a calendar change." : "Alle, für die der Kalender freigegeben ist, sehen ohne die App zu öffnen, wie viele zugesagt haben. Dafür wird der Kalendereintrag nach jeder Antwort neu geschrieben, und Nextcloud meldet jeden dieser Schreibvorgänge als Kalenderänderung.",
"Highlights" : "Highlights",
"Most maybe answers" : "Meiste Vielleicht-Antworten",
"Most times scheduled" : "Am häufigsten eingeplant",
"Never" : "Nie",
"Top attendance rate" : "Top Anwesenheitsquote",
"Top acceptance rate" : "Top Zusagequote",
"Top response rate" : "Top Antwortquote",
"Top scheduling rate" : "Top Einplanungsquote",
"Absent more often" : "Öfter nicht dabei",
"Choose highlights" : "Highlights auswählen"
},
"nplurals=2; plural=(n != 1);");
18 changes: 17 additions & 1 deletion l10n/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,22 @@
"Location copied" : "Ort kopiert",
"Maps app" : "Karten-App",
"Notify users who can see this appointment about its creation" : "Benutzer, die diesen Termin sehen können, über die Erstellung benachrichtigen",
"OpenStreetMap" : "OpenStreetMap"
"OpenStreetMap" : "OpenStreetMap",
"Columns" : "Spalten",
"_%n column_::_%n columns_" : ["%n Spalte","%n Spalten"],
"Scheduling rate" : "Einplanungsquote",
"Counted only over closed inquiries where somebody was scheduled" : "Zählt nur geschlossene Anfragen, bei denen jemand eingeplant wurde",
"Show the response summary in the calendar event" : "Antwortübersicht im Kalendereintrag anzeigen",
"Everyone the calendar is shared with sees how many people accepted, without opening the app. Keeping it current means writing to the event after every answer, and Nextcloud reports each of those writes as a calendar change." : "Alle, für die der Kalender freigegeben ist, sehen ohne die App zu öffnen, wie viele zugesagt haben. Dafür wird der Kalendereintrag nach jeder Antwort neu geschrieben, und Nextcloud meldet jeden dieser Schreibvorgänge als Kalenderänderung.",
"Highlights" : "Highlights",
"Most maybe answers" : "Meiste Vielleicht-Antworten",
"Most times scheduled" : "Am häufigsten eingeplant",
"Never" : "Nie",
"Top attendance rate" : "Top Anwesenheitsquote",
"Top acceptance rate" : "Top Zusagequote",
"Top response rate" : "Top Antwortquote",
"Top scheduling rate" : "Top Einplanungsquote",
"Absent more often" : "Öfter nicht dabei",
"Choose highlights" : "Highlights auswählen"
},"pluralForm" :"nplurals=2; plural=(n != 1);"
}
3 changes: 2 additions & 1 deletion lib/Controller/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ public function getSettings(): DataResponse {
'enabled' => $this->configService->isOrgCalendarEnabled(),
'calendarUri' => $this->configService->getOrgCalendarUri() ?: null,
'userId' => $this->configService->getOrgCalendarUserId() ?: null,
'summary' => $this->configService->isOrgCalendarSummaryEnabled(),
],
'audit' => [
'enabled' => $this->configService->isAuditLogEnabled(),
Expand Down Expand Up @@ -190,7 +191,7 @@ public function getSettings(): DataResponse {
* @param ?array<string, array{mode: string, groups: list<string>}> $permissions Permission name to access mode (all|groups|nobody) and group IDs
* @param ?array{enabled?: bool, reminderDays?: int, reminderFrequency?: int, reminderTarget?: string} $reminders Reminder settings
* @param ?array{enabled?: bool} $calendarSync Calendar sync settings
* @param ?array{enabled?: bool, calendarUri?: string} $orgCalendar Organization calendar settings (target calendar for automatic event creation)
* @param ?array{enabled?: bool, calendarUri?: string, summary?: bool} $orgCalendar Organization calendar settings (target calendar for automatic event creation)
* @param ?array{enabled?: bool, visibility?: string} $audit Audit log settings (master switch + read visibility)
* @param ?string $displayOrder Display order for appointments: chronological, name, or group
* @param ?bool $pushEnabled Whether push notifications are enabled
Expand Down
10 changes: 8 additions & 2 deletions lib/Db/AttendanceResponseMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace OCA\Attendance\Db;

use OCA\Attendance\Service\BookingService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
Expand Down Expand Up @@ -81,15 +82,15 @@ public function findByAppointment(int $appointmentId): array {
* @param list<int> $appointmentIds
* @param ?string $userId Restrict to one person, for the drill-down
* @param bool $withComments Read the comment column too — only the drill-down can afford to, it being one person's rows
* @return list<array{appointmentId: int, userId: string, response: ?string, checkinState: ?string, comment?: ?string}>
* @return list<array{appointmentId: int, userId: string, response: ?string, checkinState: ?string, bookingStatus: ?string, comment?: ?string}>
*/
public function findStatisticsRows(array $appointmentIds, ?string $userId = null, bool $withComments = false): array {
if ($appointmentIds === []) {
return [];
}

$qb = $this->db->getQueryBuilder();
$qb->select('appointment_id', 'user_id', 'response', 'checkin_state')
$qb->select('appointment_id', 'user_id', 'response', 'checkin_state', 'booking_status', 'booking_notified_status')
->from($this->getTableName())
->where(
$qb->expr()->in('appointment_id', $qb->createNamedParameter($appointmentIds, IQueryBuilder::PARAM_INT_ARRAY))
Expand All @@ -113,6 +114,11 @@ public function findStatisticsRows(array $appointmentIds, ?string $userId = null
'userId' => (string)$row['user_id'],
'response' => $row['response'] !== null ? (string)$row['response'] : null,
'checkinState' => $row['checkin_state'] !== null ? (string)$row['checkin_state'] : null,
// The status as the person was actually told it
'bookingStatus' => BookingService::effectiveStatusOf(
$row['booking_status'] !== null ? (string)$row['booking_status'] : null,
$row['booking_notified_status'] !== null ? (string)$row['booking_notified_status'] : null,
),
];
if ($withComments) {
$mapped['comment'] = isset($row['comment']) ? (string)$row['comment'] : null;
Expand Down
17 changes: 17 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
* enabled: bool,
* calendarUri: ?string,
* userId: ?string,
* summary: bool,
* }
* @psalm-type AttendanceWritableCalendar = array{
* uri: string,
Expand Down Expand Up @@ -337,9 +338,14 @@
* notRecorded: int,
* attendanceBase: int,
* noShow: int,
* scheduled: int,
* notScheduled: int,
* schedulingBase: int,
* responseRate: ?float,
* acceptRate: ?float,
* attendanceRate: ?float,
* absenceRate: ?float,
* scheduledRate: ?float,
* }
* @psalm-type AttendanceStatisticsTotals = array{
* targetCount: int,
Expand All @@ -352,9 +358,14 @@
* notRecorded: int,
* attendanceBase: int,
* noShow: int,
* scheduled: int,
* notScheduled: int,
* schedulingBase: int,
* responseRate: ?float,
* acceptRate: ?float,
* attendanceRate: ?float,
* absenceRate: ?float,
* scheduledRate: ?float,
* }
* @psalm-type AttendanceStatisticsSection = array{
* id: string,
Expand All @@ -370,9 +381,14 @@
* notRecorded: int,
* attendanceBase: int,
* noShow: int,
* scheduled: int,
* notScheduled: int,
* schedulingBase: int,
* responseRate: ?float,
* acceptRate: ?float,
* attendanceRate: ?float,
* absenceRate: ?float,
* scheduledRate: ?float,
* }
* @psalm-type AttendanceStatisticsTimelinePoint = array{
* appointmentId: int,
Expand All @@ -399,6 +415,7 @@
* pastCount: int,
* attendanceRecordedCount: int,
* groupBy: string,
* schedulingEnabled: bool,
* people: list<AttendanceStatisticsPerson>,
* sections: list<AttendanceStatisticsSection>,
* totals: AttendanceStatisticsTotals,
Expand Down
12 changes: 11 additions & 1 deletion lib/Service/BookingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,17 @@ public function isScheduledIn(Appointment $appointment, string $userId): bool {
* appointment where planning was never used stays unmarked.
*/
public function effectiveBookingStatus(AttendanceResponse $response): ?string {
return $response->getBookingStatus() ?? $response->getBookingNotifiedStatus();
return self::effectiveStatusOf($response->getBookingStatus(), $response->getBookingNotifiedStatus());
}

/**
* The same precedence over raw column values, for callers that read the two
* columns without hydrating the entity — the statistics evaluation reads
* hundreds of thousands of rows and exists precisely to skip that. The rule
* itself must not be restated there; it lives here.
*/
public static function effectiveStatusOf(?string $bookingStatus, ?string $notifiedStatus): ?string {
return $bookingStatus ?? $notifiedStatus;
}

/**
Expand Down
26 changes: 26 additions & 0 deletions lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,32 @@ public function setOrgCalendarUserId(string $userId): void {
$this->config->setAppValue(self::APP_ID, 'org_calendar_user_id', $userId);
}

/**
* Whether the response summary is carried in the organization calendar
* event's description.
*
* Every write to a calendar object raises the DAV activity "X updated event
* Y in calendar Z" for the owner and everybody the calendar is shared with —
* unconditionally, without comparing content. Carrying the summary therefore
* costs one such activity per answer. Nextcloud's own counter-setting is per
* activity type, not per calendar, so switching it off there silences every
* calendar the person has. This switch is the narrow one.
*
* Defaults to on, so an install that already carries the summary keeps it.
*/
public function isOrgCalendarSummaryEnabled(): bool {
return $this->appConfig->getValueBool(self::APP_ID, 'org_calendar_summary', true);
}

/**
* Set whether the response summary is carried in the calendar event.
*
* @param bool $enabled Whether the summary should be carried
*/
public function setOrgCalendarSummaryEnabled(bool $enabled): void {
$this->appConfig->setValueBool(self::APP_ID, 'org_calendar_summary', $enabled);
}

/**
* Check if push notifications are enabled.
*
Expand Down
27 changes: 27 additions & 0 deletions lib/Service/IcalService.php
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,33 @@ public function foldIcalContent(string $content): string {
return implode("\r\n", $folded) . "\r\n";
}

/**
* The inverse of foldIcalContent(): normalize line endings and rejoin the
* continuation lines, so every property is one array entry again. Lives
* beside its counterpart because both encode the same RFC 5545 Section 3.1
* rule, and a reader that disagrees with the writer about where a line ends
* corrupts whatever it parses.
*
* Static because it is a pure transform, so callers can reach it without a
* container and tests never have to restate the rule in a stub.
*
* @return list<string>
*/
public static function unfoldIcalContent(string $content): array {
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
$normalized = preg_replace("/\n[ \t]/", '', $normalized) ?? $normalized;

return explode("\n", trim($normalized));
}

/**
* The property name of an unfolded line, upper-cased and without its
* parameters — "DTSTART" for both `DTSTART:…` and `DTSTART;TZID=…:…`.
*/
public static function icalPropertyName(string $line): string {
return strtoupper(substr($line, 0, strcspn($line, ';:')));
}

/**
* Fold a single iCal line to max 75 octets, preserving UTF-8 boundaries.
*/
Expand Down
Loading
Loading