From 43b983d02f4a83ec8ca66c321076aa31a065a918 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 23:57:56 +0200 Subject: [PATCH 01/14] feat(timetable): the generator input from learniq's event or two uploaded sheets --- appinfo/routes.php | 2 + lib/Controller/TimetableInputController.php | 109 ++++++++ lib/Event/TimetableActivitiesQueryEvent.php | 148 ++++++++++ lib/Service/TimetableCsvParser.php | 264 ++++++++++++++++++ lib/Service/TimetableInputBuilder.php | 202 ++++++++++++++ lib/Timetabling/SolverInput.php | 85 ++++++ .../changes/timetabling-generator/tasks.md | 6 +- .../TimetableInputControllerTest.php | 146 ++++++++++ tests/unit/Service/TimetableCsvParserTest.php | 120 ++++++++ .../Service/TimetableInputBuilderTest.php | 183 ++++++++++++ 10 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 lib/Controller/TimetableInputController.php create mode 100644 lib/Event/TimetableActivitiesQueryEvent.php create mode 100644 lib/Service/TimetableCsvParser.php create mode 100644 lib/Service/TimetableInputBuilder.php create mode 100644 lib/Timetabling/SolverInput.php create mode 100644 tests/unit/Controller/TimetableInputControllerTest.php create mode 100644 tests/unit/Service/TimetableCsvParserTest.php create mode 100644 tests/unit/Service/TimetableInputBuilderTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 850e9e71..7303e607 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,4 +53,6 @@ ['name' => 'timetable#upsert', 'url' => '/api/timetable/sessions/upsert', 'verb' => 'POST'], // Admins publish a source's draft lessons in a window (timetable-draft-review). ['name' => 'timetable#publish', 'url' => '/api/timetable/sessions/publish', 'verb' => 'POST'], + // Admins upload the activities and rooms sheets for the timetable generator (timetabling-generator 2.2). + ['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'], ]); diff --git a/lib/Controller/TimetableInputController.php b/lib/Controller/TimetableInputController.php new file mode 100644 index 00000000..cff85fe1 --- /dev/null +++ b/lib/Controller/TimetableInputController.php @@ -0,0 +1,109 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Controller; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Takes the uploaded rooms and activities sheets. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ +class TimetableInputController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param TimetableCsvParser $parser Reads the sheets. + * @param TimetableInputBuilder $inputBuilder Keeps the parsed sheets. + * @param SettingsService $settingsService Answers whether the caller is an admin. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly TimetableCsvParser $parser, + private readonly TimetableInputBuilder $inputBuilder, + private readonly SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Parse and keep the rooms and activities sheets. Admins only. + * + * Nextcloud's admin middleware refuses everyone else before this runs; the + * explicit check is defence in depth, as in TimetableController::upsert(). + * Nothing is kept when either sheet has a refused line. + * + * @return JSONResponse 200 with the counts; 400 with the refused lines; 403 for a non-admin. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function upload(): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can upload timetable activities.'], Http::STATUS_FORBIDDEN); + } + + $roomsCsv = $this->request->getParam('rooms'); + $activitiesCsv = $this->request->getParam('activities'); + if (is_string($roomsCsv) === false || is_string($activitiesCsv) === false) { + return new JSONResponse(['error' => 'Send the rooms and the activities sheet as text.'], Http::STATUS_BAD_REQUEST); + } + + $rooms = $this->parser->parseRooms(csv: $roomsCsv); + $types = array_values(array_unique(array_column($rooms['rows'], 'type'))); + $activities = $this->parser->parseActivities(csv: $activitiesCsv, roomTypes: $types); + $errors = [ + 'rooms' => $rooms['errors'], + 'activities' => $activities['errors'], + ]; + if ($rooms['errors'] !== [] || $activities['errors'] !== []) { + return new JSONResponse(['error' => 'Some lines were refused. Nothing was kept.', 'errors' => $errors], Http::STATUS_BAD_REQUEST); + } + + $this->inputBuilder->storeUpload(rooms: $rooms['rows'], activities: $activities['rows']); + + return new JSONResponse( + [ + 'rooms' => count($rooms['rows']), + 'activities' => count($activities['rows']), + 'lessons' => array_sum(array_column($activities['rows'], 'lessonsPerWeek')), + ] + ); + }//end upload() +}//end class diff --git a/lib/Event/TimetableActivitiesQueryEvent.php b/lib/Event/TimetableActivitiesQueryEvent.php new file mode 100644 index 00000000..6dac14cc --- /dev/null +++ b/lib/Event/TimetableActivitiesQueryEvent.php @@ -0,0 +1,148 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Event; + +use OCP\EventDispatcher\Event; + +/** + * A request for the activities and rooms of one academic year. + * + * An activity row: group, subject, teacher (a Nextcloud user id), lessonsPerWeek, + * lessonLength (in periods) and roomType. A room row: reference, capacity, type. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableActivitiesQueryEvent extends Event { + + /** + * Contract version of this event and its answer. + * + * @var integer + */ + public const CONTRACT_VERSION = 1; + + /** + * The activities, once an app answered. + * + * @var array>|null + */ + private ?array $activities = null; + + /** + * The rooms, once an app answered. + * + * @var array> + */ + private array $rooms = []; + + /** + * The app that answered. + * + * @var string|null + */ + private ?string $answeredBy = null; + + /** + * Constructor. + * + * @param string $academicYear The academic year asked for, such as `2026-2027`. + * + * @return void + */ + public function __construct( + private readonly string $academicYear, + ) { + parent::__construct(); + }//end __construct() + + /** + * The academic year asked for. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAcademicYear(): string { + return $this->academicYear; + }//end getAcademicYear() + + /** + * Answer the event with the year's activities and rooms. + * + * @param string $app The answering app, such as `learniq`. + * @param array> $activities Activity rows. + * @param array> $rooms Room rows. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function answer(string $app, array $activities, array $rooms): void { + $this->answeredBy = $app; + $this->activities = array_values($activities); + $this->rooms = array_values($rooms); + }//end answer() + + /** + * The activities, or null when no app answered. + * + * @return array>|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getActivities(): ?array { + return $this->activities; + }//end getActivities() + + /** + * The rooms the answering app sent. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getRooms(): array { + return $this->rooms; + }//end getRooms() + + /** + * The app that answered, or null. + * + * @return string|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAnsweredBy(): ?string { + return $this->answeredBy; + }//end getAnsweredBy() +}//end class diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php new file mode 100644 index 00000000..f2ce499f --- /dev/null +++ b/lib/Service/TimetableCsvParser.php @@ -0,0 +1,264 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +/** + * Parses the rooms and activities sheets into the rows the input builder reads. + * + * A result is {rows, errors}; an error is {line, field, code, message}. Codes: + * `header`, `empty`, `whole-number`, `unknown-room-type`. The header check + * ignores case and surrounding spaces; a comma or a semicolon separates columns. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ +class TimetableCsvParser { + + /** + * The rooms sheet columns, in order. + * + * @var array + */ + public const ROOM_COLUMNS = ['reference', 'capacity', 'type']; + + /** + * The activities sheet columns, in order. + * + * @var array + */ + public const ACTIVITY_COLUMNS = ['group', 'subject', 'teacher', 'lessons per week', 'lesson length', 'room type']; + + /** + * The row key each activities column is stored under. + * + * @var array + */ + private const ACTIVITY_KEYS = [ + 'group' => 'group', + 'subject' => 'subject', + 'teacher' => 'teacher', + 'lessons per week' => 'lessonsPerWeek', + 'lesson length' => 'lessonLength', + 'room type' => 'roomType', + ]; + + /** + * Parse the rooms sheet. + * + * @param string $csv The sheet as text. + * + * @return array{rows:array,errors:array} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function parseRooms(string $csv): array { + $rows = []; + $errors = []; + foreach ($this->records(csv: $csv, columns: self::ROOM_COLUMNS, errors: $errors) as $line => $cells) { + $capacity = $this->wholeNumber(value: $cells['capacity'], line: $line, field: 'capacity', errors: $errors); + if ($this->filled(cells: $cells, line: $line, errors: $errors) === false || $capacity === null) { + continue; + } + + $rows[] = ['reference' => $cells['reference'], 'capacity' => $capacity, 'type' => $cells['type']]; + } + + return ['rows' => $rows, 'errors' => $errors]; + }//end parseRooms() + + /** + * Parse the activities sheet; every room type must be one of the rooms' types. + * + * @param string $csv The sheet as text. + * @param array $roomTypes The room types the rooms sheet holds. + * + * @return array{rows:array>,errors:array} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function parseActivities(string $csv, array $roomTypes): array { + $rows = []; + $errors = []; + foreach ($this->records(csv: $csv, columns: self::ACTIVITY_COLUMNS, errors: $errors) as $line => $cells) { + $lessons = $this->wholeNumber(value: $cells['lessons per week'], line: $line, field: 'lessons per week', errors: $errors); + $length = $this->wholeNumber(value: $cells['lesson length'], line: $line, field: 'lesson length', errors: $errors); + $filled = $this->filled(cells: $cells, line: $line, errors: $errors); + $known = $this->knownRoomType(type: $cells['room type'], roomTypes: $roomTypes, line: $line, errors: $errors); + if ($filled === false || $known === false || $lessons === null || $length === null) { + continue; + } + + $row = []; + foreach (self::ACTIVITY_KEYS as $column => $key) { + $row[$key] = $cells[$column]; + } + + $row['lessonsPerWeek'] = $lessons; + $row['lessonLength'] = $length; + $rows[] = $row; + } + + return ['rows' => $rows, 'errors' => $errors]; + }//end parseActivities() + + /** + * The data lines by line number, cells keyed by column; a wrong header adds one error and yields nothing. + * + * @param string $csv The sheet. + * @param array $columns The expected columns. + * @param array $errors Errors, appended to. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function records(string $csv, array $columns, array &$errors): array { + $lines = preg_split('/\r\n|\r|\n/', trim($csv)); + if ($lines === false) { + $lines = []; + } + + $head = array_map(static fn (string $cell): string => mb_strtolower(trim($cell)), $this->cells(line: (string)($lines[0] ?? ''))); + if ($head !== $columns) { + $errors[] = $this->error(line: 1, field: '', code: 'header', message: 'The first line must be: '.implode(', ', $columns).'.'); + return []; + } + + $records = []; + foreach (array_slice($lines, 1, null, true) as $index => $line) { + if (trim($line) === '') { + continue; + } + + $cells = array_pad(array_map('trim', $this->cells(line: $line)), count($columns), ''); + $records[$index + 1] = array_combine($columns, array_slice($cells, 0, count($columns))); + } + + return $records; + }//end records() + + /** + * The cells of one line, split on the separator the line uses. + * + * @param string $line One line. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function cells(string $line): array { + $separator = ','; + if (substr_count($line, ';') > substr_count($line, ',')) { + $separator = ';'; + } + + return array_map('strval', str_getcsv($line, $separator, '"', '')); + }//end cells() + + /** + * Whether no cell is empty; each empty cell adds an error. + * + * @param array $cells The line's cells. + * @param int $line The line number. + * @param array $errors Errors, appended to. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function filled(array $cells, int $line, array &$errors): bool { + $filled = true; + foreach ($cells as $field => $value) { + if ($value === '') { + $errors[] = $this->error(line: $line, field: $field, code: 'empty', message: "Line {$line}: {$field} is empty."); + $filled = false; + } + } + + return $filled; + }//end filled() + + /** + * A whole number of 1 or more, or null with an error; an empty cell is left to filled(). + * + * @param string $value The cell. + * @param int $line The line number. + * @param string $field The column. + * @param array $errors Errors, appended to. + * + * @return int|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function wholeNumber(string $value, int $line, string $field, array &$errors): ?int { + if (preg_match('/^\d+$/', $value) === 1 && (int)$value >= 1) { + return (int)$value; + } + + if ($value !== '') { + $errors[] = $this->error(line: $line, field: $field, code: 'whole-number', message: "Line {$line}: {$field} must be a whole number of 1 or more."); + } + + return null; + }//end wholeNumber() + + /** + * Whether a room type is one of the rooms' types; an unknown one adds an error. + * + * @param string $type The cell. + * @param array $roomTypes The known types. + * @param int $line The line number. + * @param array $errors Errors, appended to. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function knownRoomType(string $type, array $roomTypes, int $line, array &$errors): bool { + if ($type === '' || in_array($type, $roomTypes, true) === true) { + return true; + } + + $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: "Line {$line}: room type {$type} is not on the rooms sheet."); + return false; + }//end knownRoomType() + + /** + * One refusal. + * + * @param int $line The line number. + * @param string $field The column, empty for the header. + * @param string $code The refusal code. + * @param string $message A readable refusal. + * + * @return array{line:int,field:string,code:string,message:string} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function error(int $line, string $field, string $code, string $message): array { + return ['line' => $line, 'field' => $field, 'code' => $code, 'message' => $message]; + }//end error() +}//end class diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php new file mode 100644 index 00000000..5526c9b3 --- /dev/null +++ b/lib/Service/TimetableInputBuilder.php @@ -0,0 +1,202 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Timetabling\SolverInput; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; + +/** + * Builds the SolverInput of a generator run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableInputBuilder { + + /** + * App config key holding the uploaded rooms sheet, parsed. + * + * @var string + */ + public const CSV_ROOMS_KEY = 'timetable_csv_rooms'; + + /** + * App config key holding the uploaded activities sheet, parsed. + * + * @var string + */ + public const CSV_ACTIVITIES_KEY = 'timetable_csv_activities'; + + /** + * Why an input is empty when nothing supplied activities. + * + * @var string + */ + public const NO_ACTIVITIES = 'No activities: learniq did not answer and no CSV was uploaded'; + + /** + * The wish fields the solver reads. + * + * @var array + */ + private const WISH_FIELDS = ['appliesTo', 'reference', 'kind', 'periods', 'limit', 'strength', 'weight']; + + /** + * Constructor. + * + * @param IEventDispatcher $dispatcher Dispatches the activities query. + * @param IAppConfig $appConfig Holds the uploaded sheets. + * @param TimetableGridService $grid The week grid. + * + * @return void + */ + public function __construct( + private readonly IEventDispatcher $dispatcher, + private readonly IAppConfig $appConfig, + private readonly TimetableGridService $grid, + ) { + }//end __construct() + + /** + * The input of a run for one academic year and the given wishes. + * + * @param string $academicYear The academic year, such as `2026-2027`. + * @param array> $wishes timetableWish objects. + * + * @return SolverInput + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function build(string $academicYear, array $wishes): SolverInput { + $periods = $this->grid->periodKeys(); + $wishes = array_map(fn (array $wish): array => $this->wish(wish: $wish), array_values($wishes)); + + $event = new TimetableActivitiesQueryEvent(academicYear: $academicYear); + $this->dispatcher->dispatchTyped($event); + + $activities = ($event->getActivities() ?? []); + $rooms = $event->getRooms(); + $source = (string)$event->getAnsweredBy(); + if ($activities === []) { + $activities = $this->stored(key: self::CSV_ACTIVITIES_KEY); + $rooms = $this->stored(key: self::CSV_ROOMS_KEY); + $source = 'csv'; + } + + if ($activities === []) { + return new SolverInput(periods: $periods, rooms: [], lessons: [], wishes: $wishes, source: 'none', reason: self::NO_ACTIVITIES); + } + + return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $this->lessons(activities: $activities), wishes: $wishes, source: $source); + }//end build() + + /** + * Keep an uploaded rooms and activities sheet as the input for runs no app answers. + * + * @param array> $rooms Parsed room rows. + * @param array> $activities Parsed activity rows. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function storeUpload(array $rooms, array $activities): void { + $this->appConfig->setValueString(Application::APP_ID, self::CSV_ROOMS_KEY, (string)json_encode(array_values($rooms)), lazy: true); + $this->appConfig->setValueString(Application::APP_ID, self::CSV_ACTIVITIES_KEY, (string)json_encode(array_values($activities)), lazy: true); + }//end storeUpload() + + /** + * One lesson per weekly lesson of each activity, keyed `{group}:{subject}:{n}`. + * + * @param array> $activities Activity rows. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + private function lessons(array $activities): array { + $lessons = []; + foreach ($activities as $activity) { + $key = ((string)($activity['group'] ?? '')).':'.((string)($activity['subject'] ?? '')); + $count = max(0, (int)($activity['lessonsPerWeek'] ?? 0)); + for ($number = 1; $number <= $count; $number++) { + $lessons[] = [ + 'key' => $key.':'.$number, + 'activity' => $key, + 'group' => (string)($activity['group'] ?? ''), + 'subject' => (string)($activity['subject'] ?? ''), + 'teacher' => (string)($activity['teacher'] ?? ''), + 'roomType' => (string)($activity['roomType'] ?? ''), + 'length' => max(1, (int)($activity['lessonLength'] ?? 1)), + ]; + } + } + + return $lessons; + }//end lessons() + + /** + * A wish as the solver reads it: its id and the fields that constrain. + * + * @param array $wish A timetableWish object. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + private function wish(array $wish): array { + $out = ['id' => (string)($wish['id'] ?? ($wish['@self']['id'] ?? ''))]; + foreach (self::WISH_FIELDS as $field) { + if (array_key_exists($field, $wish) === true) { + $out[$field] = $wish[$field]; + } + } + + return $out; + }//end wish() + + /** + * A stored sheet, or an empty list. + * + * @param string $key The app config key. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function stored(string $key): array { + $rows = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '[]', lazy: true), true); + if (is_array($rows) === false) { + return []; + } + + return array_values(array_filter($rows, 'is_array')); + }//end stored() +}//end class diff --git a/lib/Timetabling/SolverInput.php b/lib/Timetabling/SolverInput.php new file mode 100644 index 00000000..acab68ee --- /dev/null +++ b/lib/Timetabling/SolverInput.php @@ -0,0 +1,85 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The input of one generator run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +final class SolverInput { + + /** + * Constructor. + * + * @param array $periods Period keys, such as `mon-1`. + * @param array> $rooms Rooms: reference, capacity, type. + * @param array> $lessons Lessons: key, activity, group, subject, teacher, roomType, length. + * @param array> $wishes Wishes: id, appliesTo, reference, kind, periods, limit, strength, weight. + * @param string $source Where the activities came from: an app id, `csv` or `none`. + * @param string|null $reason Why the input is empty, or null. + * + * @return void + */ + public function __construct( + public readonly array $periods, + public readonly array $rooms, + public readonly array $lessons, + public readonly array $wishes, + public readonly string $source, + public readonly ?string $reason=null, + ) { + }//end __construct() + + /** + * Whether there is nothing to place. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function isEmpty(): bool { + return $this->lessons === []; + }//end isEmpty() + + /** + * The shape a timetableScenario keeps under `input`. + * + * @return array{periods:array,rooms:array>,lessons:array>,wishes:array>,source:string} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function toArray(): array { + return [ + 'periods' => $this->periods, + 'rooms' => $this->rooms, + 'lessons' => $this->lessons, + 'wishes' => $this->wishes, + 'source' => $this->source, + ]; + }//end toArray() +}//end class diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index daae39bb..c4383c4e 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -10,9 +10,9 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 2. Activities and the solver input -- [ ] 2.1 `TimetableActivitiesQueryEvent` (typed, ADR-041) and `TimetableInputBuilder` that turns activities, rooms, the grid and the wishes into a `SolverInput`. Verify: PHPUnit on the builder with a fixture; an event nobody answers yields an empty input and the reason "No activities: learniq did not answer and no CSV was uploaded". -- [ ] 2.2 CSV upload of activities and rooms (same columns as the event), admin only. Verify: PHPUnit on the parser (header check, lessons per week and lesson length as whole numbers, unknown room type refused with the line number); controller refuses a non-admin. -- [ ] 2.3 Draft the learniq listener contract to `~/memcap-work/build-all/for-ruben/learniq-timetable-activities-event.md`. Verify: the file names the event class, its fields and the learniq endpoint it mirrors. +- [x] 2.1 `TimetableActivitiesQueryEvent` (typed, ADR-041) and `TimetableInputBuilder` that turns activities, rooms, the grid and the wishes into a `SolverInput`. Verify: PHPUnit on the builder with a fixture; an event nobody answers yields an empty input and the reason "No activities: learniq did not answer and no CSV was uploaded". +- [x] 2.2 CSV upload of activities and rooms (same columns as the event), admin only. Verify: PHPUnit on the parser (header check, lessons per week and lesson length as whole numbers, unknown room type refused with the line number); controller refuses a non-admin. +- [x] 2.3 Draft the learniq listener contract to `~/memcap-work/build-all/for-ruben/learniq-timetable-activities-event.md`. Verify: the file names the event class, its fields and the learniq endpoint it mirrors. ## 3. Wishes diff --git a/tests/unit/Controller/TimetableInputControllerTest.php b/tests/unit/Controller/TimetableInputControllerTest.php new file mode 100644 index 00000000..8f15c62b --- /dev/null +++ b/tests/unit/Controller/TimetableInputControllerTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Controller; + +use OCA\Planninq\Controller\TimetableInputController; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Admins only; nothing is kept when a line is refused. + */ +class TimetableInputControllerTest extends TestCase { + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A controller with the real parser and builder, the given body and caller. + * + * @param array $body The request parameters. + * @param bool $admin Whether the caller is an admin. + * + * @return TimetableInputController + */ + private function controller(array $body, bool $admin): TimetableInputController { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback(static fn (string $key): mixed => ($body[$key] ?? null)); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('isCurrentUserAdmin')->willReturn($admin); + + return new TimetableInputController( + request: $request, + parser: new TimetableCsvParser(), + inputBuilder: new TimetableInputBuilder(dispatcher: $this->createMock(IEventDispatcher::class), appConfig: $config, grid: new TimetableGridService(appConfig: $config)), + settingsService: $settings, + ); + }//end controller() + + /** + * The two sheets of the spec's school. + * + * @return array + */ + private function sheets(): array { + return [ + 'rooms' => "reference,capacity,type\nr-101,30,classroom\n", + 'activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,English,klaas,3,1,classroom\n3A,Maths,noor,2,2,classroom\n", + ]; + }//end sheets() + + /** + * An admin uploads both sheets: they are kept and counted. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAnAdminUploadIsKept(): void { + $response = $this->controller(body: $this->sheets(), admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + self::assertSame(expected: ['rooms' => 1, 'activities' => 2, 'lessons' => 5], actual: $response->getData()); + self::assertCount(expectedCount: 2, haystack: json_decode($this->stored['timetable_csv_activities'], true)); + self::assertSame(expected: 'r-101', actual: json_decode($this->stored['timetable_csv_rooms'], true)[0]['reference']); + }//end testAnAdminUploadIsKept() + + /** + * A non-admin is refused and nothing is kept; the route is admin-only. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testANonAdminIsRefused(): void { + $response = $this->controller(body: $this->sheets(), admin: false)->upload(); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertSame(expected: [], actual: $this->stored); + self::assertNotSame(expected: [], actual: (new ReflectionMethod(TimetableInputController::class, 'upload'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminIsRefused() + + /** + * A refused line keeps nothing and answers with the lines. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARefusedLineKeepsNothing(): void { + $body = ['activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,Swimming,piet,1,1,pool\n"] + $this->sheets(); + $response = $this->controller(body: $body, admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); + self::assertSame(expected: 'unknown-room-type', actual: $response->getData()['errors']['activities'][0]['code']); + self::assertSame(expected: 2, actual: $response->getData()['errors']['activities'][0]['line']); + self::assertSame(expected: [], actual: $this->stored); + + $missing = $this->controller(body: ['rooms' => 'x'], admin: true)->upload(); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $missing->getStatus()); + }//end testARefusedLineKeepsNothing() +}//end class diff --git a/tests/unit/Service/TimetableCsvParserTest.php b/tests/unit/Service/TimetableCsvParserTest.php new file mode 100644 index 00000000..675fdbf0 --- /dev/null +++ b/tests/unit/Service/TimetableCsvParserTest.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +use OCA\Planninq\Service\TimetableCsvParser; +use PHPUnit\Framework\TestCase; + +/** + * Header check, whole numbers and room types, each refusal with its line. + */ +class TimetableCsvParserTest extends TestCase { + + /** + * A good sheet: header case and spaces ignored, semicolons accepted, blank lines skipped. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAGoodSheetBecomesRows(): void { + $parser = new TimetableCsvParser(); + $rooms = $parser->parseRooms(csv: "Reference,Capacity,Type\nr-101,30,classroom\n\nlab-1,16,lab\n"); + self::assertSame(expected: [], actual: $rooms['errors']); + self::assertSame(expected: [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom'], ['reference' => 'lab-1', 'capacity' => 16, 'type' => 'lab']], actual: $rooms['rows']); + + $activities = $parser->parseActivities(csv: " Group ; Subject;Teacher;Lessons per week;Lesson length;Room type\r\n3A;Chemistry;noor;2;2;lab\r\n", roomTypes: ['classroom', 'lab']); + self::assertSame(expected: [], actual: $activities['errors']); + self::assertSame( + expected: [['group' => '3A', 'subject' => 'Chemistry', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'lab']], + actual: $activities['rows'] + ); + }//end testAGoodSheetBecomesRows() + + /** + * A sheet whose first line is not the header is refused as a whole. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAWrongHeaderIsRefused(): void { + $result = (new TimetableCsvParser())->parseActivities(csv: "group,subject,teacher,hours\n3A,English,klaas,3\n", roomTypes: ['classroom']); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertCount(expectedCount: 1, haystack: $result['errors']); + self::assertSame(expected: 1, actual: $result['errors'][0]['line']); + self::assertSame(expected: 'header', actual: $result['errors'][0]['code']); + self::assertSame(expected: 'The first line must be: group, subject, teacher, lessons per week, lesson length, room type.', actual: $result['errors'][0]['message']); + }//end testAWrongHeaderIsRefused() + + /** + * Lessons per week and lesson length must be whole numbers of 1 or more; an unknown room type is refused; each names its line. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testEachRefusalNamesItsLine(): void { + $csv = implode( + "\n", + [ + 'group,subject,teacher,lessons per week,lesson length,room type', + '3A,English,klaas,3,1,classroom', + '3A,Maths,noor,three,1,classroom', + '3B,Maths,noor,2,1.5,classroom', + '3B,Music,piet,0,1,classroom', + '3B,Swimming,piet,1,2,pool', + '3C,,piet,1,1,classroom', + ] + ); + $result = (new TimetableCsvParser())->parseActivities(csv: $csv, roomTypes: ['classroom']); + + self::assertSame(expected: ['3A:English'], actual: array_map(static fn (array $row): string => $row['group'].':'.$row['subject'], $result['rows'])); + self::assertSame( + expected: [ + [3, 'lessons per week', 'whole-number'], + [4, 'lesson length', 'whole-number'], + [5, 'lessons per week', 'whole-number'], + [6, 'room type', 'unknown-room-type'], + [7, 'subject', 'empty'], + ], + actual: array_map(static fn (array $error): array => [$error['line'], $error['field'], $error['code']], $result['errors']) + ); + self::assertSame(expected: 'Line 6: room type pool is not on the rooms sheet.', actual: $result['errors'][3]['message']); + }//end testEachRefusalNamesItsLine() + + /** + * A room capacity must be a whole number too. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARoomCapacityMustBeAWholeNumber(): void { + $result = (new TimetableCsvParser())->parseRooms(csv: "reference,capacity,type\nr-101,thirty,classroom\n"); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertSame(expected: 'Line 2: capacity must be a whole number of 1 or more.', actual: $result['errors'][0]['message']); + }//end testARoomCapacityMustBeAWholeNumber() +}//end class diff --git a/tests/unit/Service/TimetableInputBuilderTest.php b/tests/unit/Service/TimetableInputBuilderTest.php new file mode 100644 index 00000000..4e9cbbc4 --- /dev/null +++ b/tests/unit/Service/TimetableInputBuilderTest.php @@ -0,0 +1,183 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; + +/** + * The builder asks the hour plan's owner through the real event, falls back to + * the uploaded sheets, and says why an input is empty. + */ +class TimetableInputBuilderTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A builder whose dispatcher runs $listener on the real event, as learniq's listener would. + * + * @param callable|null $listener Receives the TimetableActivitiesQueryEvent, or null for nobody listening. + * + * @return TimetableInputBuilder + */ + private function builder(?callable $listener): TimetableInputBuilder { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use ($listener): void { + if ($listener !== null && $event instanceof TimetableActivitiesQueryEvent) { + $listener($event); + } + } + ); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + return new TimetableInputBuilder(dispatcher: $dispatcher, appConfig: $config, grid: new TimetableGridService(appConfig: $config)); + }//end builder() + + /** + * Learniq's hour plan fixture: 3A has three English lessons and two double maths lessons. + * + * @return array{0:array>,1:array>} + */ + private function hourPlan(): array { + return [ + [ + ['group' => '3A', 'subject' => 'English', 'teacher' => 'klaas', 'lessonsPerWeek' => 3, 'lessonLength' => 1, 'roomType' => 'classroom'], + ['group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'classroom'], + ], + [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom']], + ]; + }//end hourPlan() + + /** + * An answered event gives one lesson per weekly lesson, the grid's forty periods and the wishes with their ids. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnAnsweredEventBecomesLessons(): void { + [$activities, $rooms] = $this->hourPlan(); + $asked = null; + $input = $this->builder( + static function (TimetableActivitiesQueryEvent $event) use ($activities, $rooms, &$asked): void { + $asked = $event->getAcademicYear(); + $event->answer(app: 'learniq', activities: $activities, rooms: $rooms); + } + )->build( + academicYear: '2026-2027', + wishes: [['@self' => ['id' => 'w-1'], 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard', 'note' => 'not needed']] + ); + + self::assertSame(expected: '2026-2027', actual: $asked); + self::assertSame(expected: 'learniq', actual: $input->source); + self::assertNull(actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + self::assertSame(expected: ['mon-1', 'mon-2'], actual: array_slice($input->periods, 0, 2)); + self::assertSame(expected: 'fri-8', actual: $input->periods[39]); + self::assertSame(expected: $rooms, actual: $input->rooms); + self::assertSame(expected: ['3A:English:1', '3A:English:2', '3A:English:3', '3A:Maths:1', '3A:Maths:2'], actual: array_column($input->lessons, 'key')); + self::assertSame( + expected: ['key' => '3A:Maths:1', 'activity' => '3A:Maths', 'group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'roomType' => 'classroom', 'length' => 2], + actual: $input->lessons[3] + ); + self::assertSame( + expected: [['id' => 'w-1', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard']], + actual: $input->wishes + ); + + $scenario = ['title' => 'Autumn', 'source' => 'generated', 'weekOf' => '2026-10-05', 'windowFrom' => '2026-10-05', 'windowTo' => '2026-10-30', 'status' => 'queued', 'input' => $input->toArray()]; + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario), message: 'the input fits the scenario schema'); + }//end testAnAnsweredEventBecomesLessons() + + /** + * Nobody answers and nothing was uploaded: an empty input and the reason. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnUnansweredEventWithoutUploadGivesTheReason(): void { + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertTrue(condition: $input->isEmpty()); + self::assertSame(expected: 'none', actual: $input->source); + self::assertSame(expected: 'No activities: learniq did not answer and no CSV was uploaded', actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + }//end testAnUnansweredEventWithoutUploadGivesTheReason() + + /** + * Nobody answers but an admin uploaded sheets: those are the input. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testUploadedSheetsAreTheFallback(): void { + [$activities, $rooms] = $this->hourPlan(); + $builder = $this->builder(listener: null); + $builder->storeUpload(rooms: $rooms, activities: $activities); + + $input = $builder->build(academicYear: '2026-2027', wishes: []); + self::assertSame(expected: 'csv', actual: $input->source); + self::assertCount(expectedCount: 5, haystack: $input->lessons); + self::assertSame(expected: $rooms, actual: $input->rooms); + }//end testUploadedSheetsAreTheFallback() + + /** + * A narrower grid gives fewer period keys. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testThePeriodsFollowTheStoredGrid(): void { + $this->stored['timetable_period_grid'] = '{"days":["mon","wed"],"periods":[{"start":"08:00","end":"08:45"},{"start":"08:45","end":"09:30"}]}'; + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertSame(expected: ['mon-1', 'mon-2', 'wed-1', 'wed-2'], actual: $input->periods); + }//end testThePeriodsFollowTheStoredGrid() +}//end class From e40b82c9584125eefd36a032941993380d4e60b1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:02:46 +0200 Subject: [PATCH 02/14] style(timetable): two lines under 150 characters (phpcs) --- lib/Service/TimetableCsvParser.php | 3 ++- lib/Service/TimetableInputBuilder.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php index f2ce499f..0c00df08 100644 --- a/lib/Service/TimetableCsvParser.php +++ b/lib/Service/TimetableCsvParser.php @@ -242,7 +242,8 @@ private function knownRoomType(string $type, array $roomTypes, int $line, array return true; } - $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: "Line {$line}: room type {$type} is not on the rooms sheet."); + $message = "Line {$line}: room type {$type} is not on the rooms sheet."; + $errors[] = $this->error(line: $line, field: 'room type', code: 'unknown-room-type', message: $message); return false; }//end knownRoomType() diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php index 5526c9b3..02c6d4a2 100644 --- a/lib/Service/TimetableInputBuilder.php +++ b/lib/Service/TimetableInputBuilder.php @@ -114,7 +114,8 @@ public function build(string $academicYear, array $wishes): SolverInput { return new SolverInput(periods: $periods, rooms: [], lessons: [], wishes: $wishes, source: 'none', reason: self::NO_ACTIVITIES); } - return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $this->lessons(activities: $activities), wishes: $wishes, source: $source); + $lessons = $this->lessons(activities: $activities); + return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $lessons, wishes: $wishes, source: $source); }//end build() /** From 621476e263657bbad76cee23742d7085508f3994 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:05:34 +0200 Subject: [PATCH 03/14] feat(timetable): the scorer measures clashes, wishes, gaps, lessons per day and room use --- lib/Timetabling/TimetableScorer.php | 384 ++++++++++++++++++ lib/Timetabling/WeekGrid.php | 135 ++++++ lib/Timetabling/WishChecker.php | 208 ++++++++++ .../changes/timetabling-generator/tasks.md | 2 +- .../unit/Timetabling/TimetableScorerTest.php | 323 +++++++++++++++ 5 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 lib/Timetabling/TimetableScorer.php create mode 100644 lib/Timetabling/WeekGrid.php create mode 100644 lib/Timetabling/WishChecker.php create mode 100644 tests/unit/Timetabling/TimetableScorerTest.php diff --git a/lib/Timetabling/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php new file mode 100644 index 00000000..29e47500 --- /dev/null +++ b/lib/Timetabling/TimetableScorer.php @@ -0,0 +1,384 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Scores placements (lesson key, first period key, room reference) against a SolverInput. + * + * Clash kinds: `teacher`, `group` and `room` (two lessons in one period), + * `roomType` (a room of another type than the lesson needs), `unknownRoom` + * and `outOfGrid` (a lesson that starts outside the grid or runs off the day). + * A broken soft wish costs its weight (1 when unset) for every lesson that breaks it. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class TimetableScorer { + + /** + * Cost of one clash; above everything else, so no clash is traded for a wish. + */ + public const CLASH_COST = 1000000; + + /** + * Cost of one broken hard wish. + */ + public const HARD_COST = 100000; + + /** + * Cost of one lesson that is not placed. + */ + public const UNPLACED_COST = 10000; + + /** + * Cost of one point of soft penalty. + */ + public const SOFT_COST = 10; + + /** + * Cost of one free period between a teacher's lessons. + */ + public const GAP_COST = 1; + + /** + * Constructor. + * + * @param WishChecker $wishes Evaluates the wishes. + * + * @return void + */ + public function __construct( + private readonly WishChecker $wishes=new WishChecker(), + ) { + }//end __construct() + + /** + * The score of a week. + * + * @param SolverInput $input What was to be placed. + * @param array $placements Where the lessons are. + * + * @return array{metrics:array,brokenWishes:array>,clashes:array>} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function score(SolverInput $input, array $placements): array { + $grid = new WeekGrid(periods: $input->periods); + $clashes = []; + $slots = $this->slots(input: $input, placements: $placements, grid: $grid, clashes: $clashes); + $clashes = array_merge($clashes, $this->doubleBookings(slots: $slots)); + $broken = $this->brokenWishes(wishes: $input->wishes, slots: $slots, grid: $grid); + + $metrics = array_merge( + [ + 'lessons' => count($input->lessons), + 'placed' => count($slots), + 'unplaced' => (count($input->lessons) - count($slots)), + 'clashes' => count($clashes), + ], + $this->wishMetrics(broken: $broken), + $this->teacherGaps(slots: $slots, grid: $grid), + ['lessonsPerDayWorst' => $this->lessonsPerDayWorst(slots: $slots)], + $this->roomUse(input: $input, slots: $slots) + ); + + return ['metrics' => $metrics, 'brokenWishes' => $broken, 'clashes' => $clashes]; + }//end score() + + /** + * One number to minimise: clashes, then hard wishes, then unplaced lessons, then soft wishes, then gaps. + * + * @param array $metrics The metrics of score(). + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public static function cost(array $metrics): int { + return ((int)$metrics['clashes'] * self::CLASH_COST) + + ((int)$metrics['hardWishesBroken'] * self::HARD_COST) + + ((int)$metrics['unplaced'] * self::UNPLACED_COST) + + ((int)$metrics['softPenalty'] * self::SOFT_COST) + + ((int)$metrics['teacherGaps'] * self::GAP_COST); + }//end cost() + + /** + * The placed lessons with the periods they take; a placement that cannot be laid out is a clash. + * + * @param SolverInput $input The input. + * @param array> $placements The placements. + * @param WeekGrid $grid The grid. + * @param array> $clashes Collects the clashes. + * + * @return array> + */ + private function slots(SolverInput $input, array $placements, WeekGrid $grid, array &$clashes): array { + $lessons = array_column($input->lessons, null, 'key'); + $rooms = array_column($input->rooms, null, 'reference'); + $order = array_flip($grid->keys()); + $slots = []; + foreach ($placements as $placement) { + $lesson = ($lessons[(string)$placement['lesson']] ?? null); + if ($lesson === null) { + continue; + } + + $room = (string)$placement['room']; + $periods = $grid->span(start: (string)$placement['period'], length: (int)($lesson['length'] ?? 1)); + $problem = $this->placementProblem(lesson: $lesson, room: ($rooms[$room] ?? null), periods: $periods); + if ($problem !== null) { + $clashes[] = ['kind' => $problem, 'reference' => $room, 'period' => (string)$placement['period'], 'lessons' => [(string)$lesson['key']]]; + } + + if ($periods === null) { + continue; + } + + $slots[] = [ + 'lesson' => $lesson, + 'room' => $room, + 'periods' => $periods, + 'day' => $grid->day(key: $periods[0]), + 'order' => (int)$order[$periods[0]], + ]; + }//end foreach + + return $slots; + }//end slots() + + /** + * What is wrong with one placement by itself, or null. + * + * @param array $lesson The lesson. + * @param array|null $room The room, or null when unknown. + * @param array|null $periods The periods, or null when off the grid. + * + * @return string|null + */ + private function placementProblem(array $lesson, ?array $room, ?array $periods): ?string { + if ($periods === null) { + return 'outOfGrid'; + } + + if ($room === null) { + return 'unknownRoom'; + } + + $needed = (string)($lesson['roomType'] ?? ''); + if ($needed !== '' && (string)($room['type'] ?? '') !== $needed) { + return 'roomType'; + } + + return null; + }//end placementProblem() + + /** + * Two lessons of one teacher, group or room in the same period: one clash per resource and period. + * + * @param array> $slots The placed lessons. + * + * @return array> + */ + private function doubleBookings(array $slots): array { + $taken = []; + foreach ($slots as $slot) { + $owners = ['teacher' => (string)$slot['lesson']['teacher'], 'group' => (string)$slot['lesson']['group'], 'room' => (string)$slot['room']]; + foreach ($slot['periods'] as $period) { + foreach ($owners as $kind => $reference) { + $taken[$kind."\n".$reference."\n".$period][] = (string)$slot['lesson']['key']; + } + } + } + + $clashes = []; + foreach ($taken as $at => $lessons) { + if (count($lessons) > 1 && explode("\n", $at)[1] !== '') { + [$kind, $reference, $period] = explode("\n", $at); + $clashes[] = ['kind' => $kind, 'reference' => $reference, 'period' => $period, 'lessons' => $lessons]; + } + } + + return $clashes; + }//end doubleBookings() + + /** + * Every broken wish with the lessons that break it. + * + * @param array> $wishes The wishes. + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function brokenWishes(array $wishes, array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($wishes as $wish) { + $lessons = $this->wishes->breakingLessons(wish: $wish, slots: $slots, grid: $grid); + if ($lessons === []) { + continue; + } + + $hard = ($wish['strength'] ?? 'soft') === 'hard'; + $broken[] = [ + 'wish' => (string)($wish['id'] ?? ''), + 'strength' => (string)($wish['strength'] ?? 'soft'), + 'weight' => $this->weight(wish: $wish, hard: $hard), + 'lessons' => $lessons, + ]; + } + + return $broken; + }//end brokenWishes() + + /** + * The weight of a broken wish: null for a hard wish, else 1 to 3 (1 when unset). + * + * @param array $wish The wish. + * @param bool $hard Whether it is hard. + * + * @return int|null + */ + private function weight(array $wish, bool $hard): ?int { + if ($hard === true) { + return null; + } + + return max(1, (int)($wish['weight'] ?? 1)); + }//end weight() + + /** + * Hard and soft wishes broken, and the weighted soft penalty. + * + * @param array> $broken The broken wishes. + * + * @return array{hardWishesBroken:int,softWishesBroken:int,softPenalty:int} + */ + private function wishMetrics(array $broken): array { + $metrics = ['hardWishesBroken' => 0, 'softWishesBroken' => 0, 'softPenalty' => 0]; + foreach ($broken as $wish) { + if ($wish['strength'] === 'hard') { + $metrics['hardWishesBroken']++; + continue; + } + + $metrics['softWishesBroken']++; + $metrics['softPenalty'] += ((int)$wish['weight'] * count($wish['lessons'])); + } + + return $metrics; + }//end wishMetrics() + + /** + * Free periods between a teacher's lessons on a day: in total and for the teacher with the most. + * + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array{teacherGaps:int,teacherGapsWorst:int} + */ + private function teacherGaps(array $slots, WeekGrid $grid): array { + $numbers = []; + foreach ($slots as $slot) { + foreach ($slot['periods'] as $period) { + $numbers[(string)$slot['lesson']['teacher']][$grid->day(key: $period)][] = $grid->number(key: $period); + } + } + + $total = 0; + $worst = 0; + foreach ($numbers as $days) { + $gaps = array_sum(array_map([$grid, 'gaps'], $days)); + $total += $gaps; + $worst = max($worst, $gaps); + } + + return ['teacherGaps' => $total, 'teacherGapsWorst' => $worst]; + }//end teacherGaps() + + /** + * The most lessons one group has on one day. + * + * @param array> $slots The placed lessons. + * + * @return int + */ + private function lessonsPerDayWorst(array $slots): int { + $counts = []; + foreach ($slots as $slot) { + $key = (string)$slot['lesson']['group']."\n".(string)$slot['day']; + $counts[$key] = (($counts[$key] ?? 0) + 1); + } + + if ($counts === []) { + return 0; + } + + return (int)max($counts); + }//end lessonsPerDayWorst() + + /** + * The share of room periods in use, overall and per room type, rounded to two decimals. + * + * @param SolverInput $input The input. + * @param array> $slots The placed lessons. + * + * @return array{roomUse:float,roomUseByType:array} + */ + private function roomUse(SolverInput $input, array $slots): array { + $types = array_column($input->rooms, 'type', 'reference'); + $used = []; + foreach ($slots as $slot) { + $type = ($types[(string)$slot['room']] ?? null); + if ($type !== null) { + $used[(string)$type] = (($used[(string)$type] ?? 0) + count($slot['periods'])); + } + } + + $periods = count($input->periods); + $byType = []; + foreach (array_count_values(array_map('strval', array_values($types))) as $type => $rooms) { + $byType[(string)$type] = $this->share(used: ($used[$type] ?? 0), available: ($rooms * $periods)); + } + + return ['roomUse' => $this->share(used: array_sum($used), available: (count($types) * $periods)), 'roomUseByType' => $byType]; + }//end roomUse() + + /** + * A share rounded to two decimals, 0.0 when nothing is available. + * + * @param int $used Used. + * @param int $available Available. + * + * @return float + */ + private function share(int $used, int $available): float { + if ($available === 0) { + return 0.0; + } + + return round($used / $available, 2); + }//end share() +}//end class diff --git a/lib/Timetabling/WeekGrid.php b/lib/Timetabling/WeekGrid.php new file mode 100644 index 00000000..196044f6 --- /dev/null +++ b/lib/Timetabling/WeekGrid.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Days and period numbers of a list of period keys such as `mon-3`. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WeekGrid { + + /** + * The period keys, as a set. + * + * @var array + */ + private array $known = []; + + /** + * Constructor. + * + * @param array $periods The period keys of the input. + * + * @return void + */ + public function __construct(private readonly array $periods) { + foreach ($periods as $key) { + $this->known[$key] = true; + } + }//end __construct() + + /** + * The period keys, in grid order. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function keys(): array { + return $this->periods; + }//end keys() + + /** + * The periods a lesson of $length periods starting at $start takes, or null when it runs off the day or starts outside the grid. + * + * @param string $start The first period key. + * @param int $length The length in periods. + * + * @return array|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function span(string $start, int $length): ?array { + $day = $this->day(key: $start); + $number = $this->number(key: $start); + $keys = []; + for ($offset = 0; $offset < max(1, $length); $offset++) { + $key = $day.'-'.($number + $offset); + if (isset($this->known[$key]) === false) { + return null; + } + + $keys[] = $key; + } + + return $keys; + }//end span() + + /** + * The day of a period key, as in `mon`. + * + * @param string $key The period key. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function day(string $key): string { + return explode('-', $key, 2)[0]; + }//end day() + + /** + * The period number of a period key, from 1. + * + * @param string $key The period key. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function number(string $key): int { + return (int)(explode('-', $key, 2)[1] ?? 0); + }//end number() + + /** + * The free periods between the first and the last of a day's taken period numbers. + * + * @param array $numbers Taken period numbers of one day. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function gaps(array $numbers): int { + if ($numbers === []) { + return 0; + } + + $unique = array_unique($numbers); + return (max($unique) - min($unique) + 1 - count($unique)); + }//end gaps() +}//end class diff --git a/lib/Timetabling/WishChecker.php b/lib/Timetabling/WishChecker.php new file mode 100644 index 00000000..cf95890e --- /dev/null +++ b/lib/Timetabling/WishChecker.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The five wish kinds, evaluated on placed lessons. + * + * A placed lesson (a slot) is: `lesson` (the input lesson), `room`, `periods` + * (the period keys it takes), `day` and `order` (the grid position of its first period). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WishChecker { + + /** + * The lesson field a wish on each subject compares with its reference. + */ + private const LESSON_FIELDS = [ + 'teacher' => 'teacher', + 'group' => 'group', + 'activity' => 'activity', + ]; + + /** + * The keys of the lessons that break the wish, in period order; empty when it is kept. + * + * @param array $wish The wish. + * @param array> $slots All placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function breakingLessons(array $wish, array $slots, WeekGrid $grid): array { + $mine = array_values( + array_filter($slots, fn (array $slot): bool => $this->applies(wish: $wish, lesson: $slot['lesson'], room: (string)$slot['room'])) + ); + usort($mine, static fn (array $one, array $two): int => $one['order'] <=> $two['order']); + + $breaking = match ((string)($wish['kind'] ?? '')) { + 'unavailable', 'avoid' => array_filter($mine, fn (array $slot): bool => $this->touches(wish: $wish, periods: $slot['periods'])), + 'maxPerDay' => $this->overTheLimit(wish: $wish, slots: $mine), + 'noGaps' => $this->daysWithGaps(slots: $mine, grid: $grid), + 'sameRoom' => $this->outsideTheMainRoom(slots: $mine), + default => [], + }; + + return array_values(array_map(static fn (array $slot): string => (string)$slot['lesson']['key'], $breaking)); + }//end breakingLessons() + + /** + * Whether placing the lesson on these periods in this room breaks the wish by itself (the period kinds only). + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param array $periods The periods it would take. + * @param string $room The room. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function forbids(array $wish, array $lesson, array $periods, string $room): bool { + if (in_array(($wish['kind'] ?? ''), ['unavailable', 'avoid'], true) === false) { + return false; + } + + return $this->applies(wish: $wish, lesson: $lesson, room: $room) === true && $this->touches(wish: $wish, periods: $periods) === true; + }//end forbids() + + /** + * Whether a wish is about this lesson. + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param string $room The room it is in. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function applies(array $wish, array $lesson, string $room): bool { + $reference = (string)($wish['reference'] ?? ''); + $appliesTo = (string)($wish['appliesTo'] ?? ''); + if ($appliesTo === 'room') { + return $room === $reference; + } + + $field = (self::LESSON_FIELDS[$appliesTo] ?? null); + return $field !== null && (string)($lesson[$field] ?? '') === $reference; + }//end applies() + + /** + * Whether the periods include one of the wish's periods. + * + * @param array $wish The wish. + * @param array $periods The periods. + * + * @return bool + */ + private function touches(array $wish, array $periods): bool { + return array_intersect($periods, (array)($wish['periods'] ?? [])) !== []; + }//end touches() + + /** + * The lessons past the wish's limit on each day. + * + * @param array $wish The wish. + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function overTheLimit(array $wish, array $slots): array { + $limit = (int)($wish['limit'] ?? 0); + if ($limit < 1) { + return []; + } + + $over = []; + foreach ($this->byDay(slots: $slots) as $day) { + $over = array_merge($over, array_slice($day, $limit)); + } + + return $over; + }//end overTheLimit() + + /** + * The lessons of every day that has a free period between two of them. + * + * @param array> $slots Its lessons, in period order. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function daysWithGaps(array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($this->byDay(slots: $slots) as $day) { + $numbers = []; + foreach ($day as $slot) { + $numbers = array_merge($numbers, array_map([$grid, 'number'], $slot['periods'])); + } + + if ($grid->gaps(numbers: $numbers) > 0) { + $broken = array_merge($broken, $day); + } + } + + return $broken; + }//end daysWithGaps() + + /** + * The lessons that are not in the room most of them use. + * + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function outsideTheMainRoom(array $slots): array { + $counts = array_count_values(array_map(static fn (array $slot): string => (string)$slot['room'], $slots)); + if (count($counts) < 2) { + return []; + } + + arsort($counts); + $main = (string)array_key_first($counts); + return array_filter($slots, static fn (array $slot): bool => (string)$slot['room'] !== $main); + }//end outsideTheMainRoom() + + /** + * Lessons grouped by day, order kept. + * + * @param array> $slots Lessons in period order. + * + * @return array>> + */ + private function byDay(array $slots): array { + $days = []; + foreach ($slots as $slot) { + $days[(string)$slot['day']][] = $slot; + } + + return $days; + }//end byDay() +}//end class diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index c4383c4e..924ea0d1 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -21,7 +21,7 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 4. Scorer -- [ ] 4.1 `TimetableScorer`: clashes, hard wish breaches, soft wish breaches with weights, teacher gaps, lessons per day, room use, as `metrics` (design decision 5). Verify: PHPUnit with a hand-made placement for each wish kind, each broken once and kept once. +- [x] 4.1 `TimetableScorer`: clashes, hard wish breaches, soft wish breaches with weights, teacher gaps, lessons per day, room use, as `metrics` (design decision 5). Verify: PHPUnit with a hand-made placement for each wish kind, each broken once and kept once. ## 5. Solver and the background run diff --git a/tests/unit/Timetabling/TimetableScorerTest.php b/tests/unit/Timetabling/TimetableScorerTest.php new file mode 100644 index 00000000..d63d5a9f --- /dev/null +++ b/tests/unit/Timetabling/TimetableScorerTest.php @@ -0,0 +1,323 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Timetabling; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\TimetableScorer; +use PHPUnit\Framework\TestCase; + +/** + * Hand-made placements on a two-day, four-period week. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +class TimetableScorerTest extends TestCase { + use RegisterSchemaValidation; + + /** + * Lessons of the fixture: klaas teaches 3A English three times, noor 3A Maths once (two periods), piet 3B Art once in a studio. + * + * @return array> + */ + private function lessons(): array { + $lesson = static fn (string $key, string $group, string $subject, string $teacher, string $roomType='classroom', int $length=1): array => [ + 'key' => $key, + 'activity' => $group.':'.$subject, + 'group' => $group, + 'subject' => $subject, + 'teacher' => $teacher, + 'roomType' => $roomType, + 'length' => $length, + ]; + + return [ + $lesson('3A:English:1', '3A', 'English', 'klaas'), + $lesson('3A:English:2', '3A', 'English', 'klaas'), + $lesson('3A:English:3', '3A', 'English', 'klaas'), + $lesson('3A:Maths:1', '3A', 'Maths', 'noor', 'classroom', 2), + $lesson('3B:Art:1', '3B', 'Art', 'piet', 'studio'), + ]; + }//end lessons() + + /** + * The input with the given wishes. + * + * @param array> $wishes The wishes. + * + * @return SolverInput + */ + private function input(array $wishes=[]): SolverInput { + return new SolverInput( + periods: ['mon-1', 'mon-2', 'mon-3', 'mon-4', 'tue-1', 'tue-2', 'tue-3', 'tue-4'], + rooms: [ + ['reference' => 'r-1', 'capacity' => 30, 'type' => 'classroom'], + ['reference' => 'r-2', 'capacity' => 30, 'type' => 'classroom'], + ['reference' => 's-1', 'capacity' => 20, 'type' => 'studio'], + ], + lessons: $this->lessons(), + wishes: $wishes, + source: 'csv', + ); + }//end input() + + /** + * A placement row. + * + * @param string $lesson The lesson key. + * @param string $period The first period. + * @param string $room The room. + * + * @return array{lesson:string,period:string,room:string} + */ + private function at(string $lesson, string $period, string $room='r-1'): array { + return ['lesson' => $lesson, 'period' => $period, 'room' => $room]; + }//end at() + + /** + * A clean week: English mon-1, mon-2, tue-1; Maths mon-3/4; Art tue-2. + * + * @return array + */ + private function cleanWeek(): array { + return [ + $this->at('3A:English:1', 'mon-1'), + $this->at('3A:English:2', 'mon-2'), + $this->at('3A:English:3', 'tue-1'), + $this->at('3A:Maths:1', 'mon-3', 'r-2'), + $this->at('3B:Art:1', 'tue-2', 's-1'), + ]; + }//end cleanWeek() + + /** + * The broken wish ids of a score. + * + * @param array $score The score. + * + * @return array + */ + private function brokenIds(array $score): array { + return array_column($score['brokenWishes'], 'wish'); + }//end brokenIds() + + /** + * A clean week has no clashes, nothing unplaced and the measures compare shows. + * + * @return void + */ + public function testACleanWeekHasTheMeasures(): void { + $score = (new TimetableScorer())->score(input: $this->input(), placements: $this->cleanWeek()); + $metrics = $score['metrics']; + + self::assertSame(expected: [], actual: $score['clashes']); + self::assertSame(expected: 5, actual: $metrics['lessons']); + self::assertSame(expected: 5, actual: $metrics['placed']); + self::assertSame(expected: 0, actual: $metrics['unplaced']); + self::assertSame(expected: 0, actual: $metrics['clashes']); + self::assertSame(expected: 0, actual: $metrics['hardWishesBroken']); + self::assertSame(expected: 0, actual: $metrics['softWishesBroken']); + self::assertSame(expected: 0, actual: $metrics['softPenalty']); + // Noor: mon-3 and mon-4: no gap. Klaas: mon-1, mon-2 and tue-1: no gap. + self::assertSame(expected: 0, actual: $metrics['teacherGaps']); + self::assertSame(expected: 0, actual: $metrics['teacherGapsWorst']); + // 3A on Monday: English twice and Maths once. + self::assertSame(expected: 3, actual: $metrics['lessonsPerDayWorst']); + // Six room-periods used of 3 rooms x 8 periods. + self::assertSame(expected: 0.25, actual: $metrics['roomUse']); + self::assertSame(expected: ['classroom' => 0.31, 'studio' => 0.13], actual: $metrics['roomUseByType']); + self::assertSame(expected: 0, actual: TimetableScorer::cost(metrics: $metrics)); + }//end testACleanWeekHasTheMeasures() + + /** + * Teacher, group and room clashes, a wrong room type, a lesson running off the day and an unknown room are counted. + * + * @return void + */ + public function testClashesAreCounted(): void { + $week = $this->cleanWeek(); + $week[1] = $this->at('3A:English:2', 'mon-1', 'r-2'); + // Teacher klaas and group 3A twice in mon-1. + $week[4] = $this->at('3B:Art:1', 'mon-4', 'r-2'); + // Room r-2 twice in mon-4 (Maths runs mon-3 and mon-4), and Art in a classroom. + $score = (new TimetableScorer())->score(input: $this->input(), placements: $week); + + $kinds = array_column($score['clashes'], 'kind'); + sort($kinds); + self::assertSame(expected: ['group', 'room', 'roomType', 'teacher'], actual: $kinds); + self::assertSame(expected: 4, actual: $score['metrics']['clashes']); + self::assertGreaterThanOrEqual(expected: 4 * TimetableScorer::CLASH_COST, actual: TimetableScorer::cost(metrics: $score['metrics'])); + + $off = $this->cleanWeek(); + $off[3] = $this->at('3A:Maths:1', 'mon-4', 'r-2'); + $off[4] = $this->at('3B:Art:1', 'tue-2', 'nowhere'); + $kinds = array_column((new TimetableScorer())->score(input: $this->input(), placements: $off)['clashes'], 'kind'); + sort($kinds); + self::assertSame(expected: ['outOfGrid', 'unknownRoom'], actual: $kinds); + }//end testClashesAreCounted() + + /** + * A lesson with no placement is unplaced. + * + * @return void + */ + public function testALessonWithoutAPlacementIsUnplaced(): void { + $week = array_slice($this->cleanWeek(), 0, 4); + $metrics = (new TimetableScorer())->score(input: $this->input(), placements: $week)['metrics']; + + self::assertSame(expected: 4, actual: $metrics['placed']); + self::assertSame(expected: 1, actual: $metrics['unplaced']); + self::assertSame(expected: TimetableScorer::UNPLACED_COST, actual: TimetableScorer::cost(metrics: $metrics)); + }//end testALessonWithoutAPlacementIsUnplaced() + + /** + * Not on these periods (hard): broken when a lesson touches one, kept otherwise. + * + * @return void + */ + public function testUnavailableHard(): void { + $wish = ['id' => 'w-un', 'appliesTo' => 'teacher', 'reference' => 'noor', 'kind' => 'unavailable', 'periods' => ['mon-4'], 'strength' => 'hard']; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['w-un'], actual: $this->brokenIds(score: $score)); + self::assertSame(expected: ['3A:Maths:1'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 'hard', actual: $score['brokenWishes'][0]['strength']); + self::assertSame(expected: 1, actual: $score['metrics']['hardWishesBroken']); + self::assertSame(expected: TimetableScorer::HARD_COST, actual: TimetableScorer::cost(metrics: $score['metrics'])); + + $wish['periods'] = ['mon-5', 'tue-3']; + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testUnavailableHard() + + /** + * Preferably not on these periods (soft, weight 2) on a group: broken, then kept. + * + * @return void + */ + public function testAvoidSoftWithWeight(): void { + $wish = ['id' => 'w-av', 'appliesTo' => 'group', 'reference' => '3A', 'kind' => 'avoid', 'periods' => ['mon-1', 'mon-2'], 'strength' => 'soft', 'weight' => 2]; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3A:English:1', '3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 2, actual: $score['brokenWishes'][0]['weight']); + self::assertSame(expected: 1, actual: $score['metrics']['softWishesBroken']); + // Weight 2 for each of the two lessons. + self::assertSame(expected: 4, actual: $score['metrics']['softPenalty']); + + $wish['periods'] = ['tue-4']; + self::assertSame(expected: 0, actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['metrics']['softPenalty']); + }//end testAvoidSoftWithWeight() + + /** + * At most N lessons a day on a teacher: the lessons over the limit are named. + * + * @return void + */ + public function testMaxPerDay(): void { + $wish = ['id' => 'w-max', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'maxPerDay', 'limit' => 1, 'strength' => 'soft', 'weight' => 1]; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + + $wish['limit'] = 2; + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testMaxPerDay() + + /** + * No free periods between lessons on a teacher: a gap breaks it; the gap also counts in the teacher gaps. + * + * @return void + */ + public function testNoGaps(): void { + $wish = ['id' => 'w-gap', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'noGaps', 'strength' => 'soft', 'weight' => 3]; + $week = $this->cleanWeek(); + $week[1] = $this->at('3A:English:2', 'tue-4'); + // Klaas on Tuesday: periods 1 and 4, two free periods between them. + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $week); + + self::assertSame(expected: ['3A:English:3', '3A:English:2'], actual: $score['brokenWishes'][0]['lessons']); + self::assertSame(expected: 2, actual: $score['metrics']['teacherGaps']); + self::assertSame(expected: 2, actual: $score['metrics']['teacherGapsWorst']); + + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testNoGaps() + + /** + * Always the same room on an activity: two rooms break it; the lessons outside the most used room are named. + * + * @return void + */ + public function testSameRoom(): void { + $wish = ['id' => 'w-room', 'appliesTo' => 'activity', 'reference' => '3A:English', 'kind' => 'sameRoom', 'strength' => 'hard']; + $week = $this->cleanWeek(); + $week[2] = $this->at('3A:English:3', 'tue-1', 'r-2'); + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $week); + + self::assertSame(expected: ['3A:English:3'], actual: $score['brokenWishes'][0]['lessons']); + + self::assertSame(expected: [], actual: (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek())['brokenWishes']); + }//end testSameRoom() + + /** + * A wish on a room applies to the lessons placed in it. + * + * @return void + */ + public function testAWishOnARoom(): void { + $wish = ['id' => 'w-r', 'appliesTo' => 'room', 'reference' => 's-1', 'kind' => 'unavailable', 'periods' => ['tue-2'], 'strength' => 'hard']; + $score = (new TimetableScorer())->score(input: $this->input(wishes: [$wish]), placements: $this->cleanWeek()); + + self::assertSame(expected: ['3B:Art:1'], actual: $score['brokenWishes'][0]['lessons']); + }//end testAWishOnARoom() + + /** + * A finished scenario carrying this score passes the real timetableScenario schema. + * + * @return void + */ + public function testTheScoreFitsTheScenarioSchema(): void { + $wishes = [ + ['id' => 'w-un', 'appliesTo' => 'teacher', 'reference' => 'noor', 'kind' => 'unavailable', 'periods' => ['mon-4'], 'strength' => 'hard'], + ['id' => 'w-av', 'appliesTo' => 'group', 'reference' => '3A', 'kind' => 'avoid', 'periods' => ['mon-1'], 'strength' => 'soft', 'weight' => 2], + ]; + $input = $this->input(wishes: $wishes); + $score = (new TimetableScorer())->score(input: $input, placements: $this->cleanWeek()); + + $scenario = [ + 'title' => 'Imported week', + 'source' => 'imported', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-30', + 'status' => 'done', + 'input' => $input->toArray(), + 'placements' => $this->cleanWeek(), + 'brokenWishes' => $score['brokenWishes'], + 'metrics' => $score['metrics'], + ]; + self::assertCount(expectedCount: 2, haystack: $score['brokenWishes']); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario)); + }//end testTheScoreFitsTheScenarioSchema() +}//end class From 388850a343a3c4855b9173509b2b7798ec37a091 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:39:30 +0200 Subject: [PATCH 04/14] feat(timetable): a timetabler lists, adds and edits wishes, hard or soft, on the week grid --- l10n/be.js | 18 +- l10n/be.json | 18 +- l10n/bg.js | 18 +- l10n/bg.json | 18 +- l10n/bs.js | 18 +- l10n/bs.json | 18 +- l10n/ca.js | 18 +- l10n/ca.json | 18 +- l10n/cs.js | 18 +- l10n/cs.json | 18 +- l10n/da.js | 18 +- l10n/da.json | 18 +- l10n/de.js | 18 +- l10n/de.json | 18 +- l10n/el.js | 18 +- l10n/el.json | 18 +- l10n/en.js | 18 +- l10n/en.json | 18 +- l10n/es.js | 18 +- l10n/es.json | 18 +- l10n/et.js | 18 +- l10n/et.json | 18 +- l10n/fi.js | 18 +- l10n/fi.json | 18 +- l10n/fr.js | 18 +- l10n/fr.json | 18 +- l10n/ga.js | 18 +- l10n/ga.json | 18 +- l10n/hr.js | 18 +- l10n/hr.json | 18 +- l10n/hu.js | 18 +- l10n/hu.json | 18 +- l10n/is.js | 18 +- l10n/is.json | 18 +- l10n/it.js | 18 +- l10n/it.json | 18 +- l10n/lb.js | 18 +- l10n/lb.json | 18 +- l10n/lt.js | 18 +- l10n/lt.json | 18 +- l10n/lv.js | 18 +- l10n/lv.json | 18 +- l10n/mk.js | 18 +- l10n/mk.json | 18 +- l10n/mt.js | 18 +- l10n/mt.json | 18 +- l10n/nb.js | 18 +- l10n/nb.json | 18 +- l10n/nl.js | 18 +- l10n/nl.json | 18 +- l10n/pl.js | 18 +- l10n/pl.json | 18 +- l10n/pt.js | 18 +- l10n/pt.json | 18 +- l10n/rm.js | 18 +- l10n/rm.json | 18 +- l10n/ro.js | 18 +- l10n/ro.json | 18 +- l10n/ru.js | 18 +- l10n/ru.json | 18 +- l10n/sk.js | 18 +- l10n/sk.json | 18 +- l10n/sl.js | 18 +- l10n/sl.json | 18 +- l10n/sq.js | 18 +- l10n/sq.json | 18 +- l10n/sr.js | 18 +- l10n/sr.json | 18 +- l10n/sv.js | 18 +- l10n/sv.json | 18 +- l10n/tr.js | 18 +- l10n/tr.json | 18 +- l10n/uk.js | 18 +- l10n/uk.json | 18 +- .../changes/timetabling-generator/tasks.md | 4 +- src/dialogs/TimetableWishDialog.vue | 407 ++++++++++++++++++ src/icons.js | 2 + src/manifest.json | 16 + src/registry.js | 12 + src/utils/timetableWishes.js | 204 +++++++++ tests/e2e/timetable-wishes.spec.ts | 77 ++++ tests/vitest/timetableWishes.spec.js | 124 ++++++ 82 files changed, 2102 insertions(+), 76 deletions(-) create mode 100644 src/dialogs/TimetableWishDialog.vue create mode 100644 src/utils/timetableWishes.js create mode 100644 tests/e2e/timetable-wishes.spec.ts create mode 100644 tests/vitest/timetableWishes.spec.js diff --git a/l10n/be.js b/l10n/be.js index 8986f7bd..c7e6af4f 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Прычына", "Why the run failed": "Чаму запуск не ўдаўся", "Published at": "Апублікаваны", - "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі" + "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі", + "Edit wish": "Рэдагаваць пажаданне", + "New wish": "Новае пажаданне", + "Hard: the generator always keeps it": "Абавязковае: генератар заўсёды яго выконвае", + "Soft: the generator keeps it where it can": "Гнуткае: генератар выконвае яго, калі можа", + "Weight (1 to 3)": "Вага (ад 1 да 3)", + "Most lessons a day": "Не больш за столькі ўрокаў у дзень", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Настаўнік (імя карыстальніка)", + "Activity (group:subject)": "Заняткі (група:прадмет)", + "Say who or what the wish is about.": "Пазначце, каго ці чаго датычыць пажаданне.", + "Choose at least one period.": "Выберыце хаця б адзін урок.", + "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", + "Timetable": "Расклад", + "Wishes": "Пажаданні", + "Timetable wishes": "Пажаданні да раскладу" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 66948d86..21316bd9 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1007,7 +1007,23 @@ "Reason": "Прычына", "Why the run failed": "Чаму запуск не ўдаўся", "Published at": "Апублікаваны", - "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі" + "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі", + "Edit wish": "Рэдагаваць пажаданне", + "New wish": "Новае пажаданне", + "Hard: the generator always keeps it": "Абавязковае: генератар заўсёды яго выконвае", + "Soft: the generator keeps it where it can": "Гнуткае: генератар выконвае яго, калі можа", + "Weight (1 to 3)": "Вага (ад 1 да 3)", + "Most lessons a day": "Не больш за столькі ўрокаў у дзень", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Настаўнік (імя карыстальніка)", + "Activity (group:subject)": "Заняткі (група:прадмет)", + "Say who or what the wish is about.": "Пазначце, каго ці чаго датычыць пажаданне.", + "Choose at least one period.": "Выберыце хаця б адзін урок.", + "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", + "Timetable": "Расклад", + "Wishes": "Пажаданні", + "Timetable wishes": "Пажаданні да раскладу" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 81136283..c96ada3b 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Защо изпълнението е неуспешно", "Published at": "Публикуван на", - "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове" + "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове", + "Edit wish": "Редактиране на желание", + "New wish": "Ново желание", + "Hard: the generator always keeps it": "Задължително: генераторът винаги го спазва", + "Soft: the generator keeps it where it can": "Гъвкаво: генераторът го спазва, когато може", + "Weight (1 to 3)": "Тежест (от 1 до 3)", + "Most lessons a day": "Най-много часове на ден", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Учител (потребителско име)", + "Activity (group:subject)": "Дейност (група:предмет)", + "Say who or what the wish is about.": "Посочете за кого или за какво е желанието.", + "Choose at least one period.": "Изберете поне един час.", + "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", + "Timetable": "Разписание", + "Wishes": "Желания", + "Timetable wishes": "Желания за разписанието" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index e73ba4d1..da51ca2b 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Защо изпълнението е неуспешно", "Published at": "Публикуван на", - "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове" + "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове", + "Edit wish": "Редактиране на желание", + "New wish": "Ново желание", + "Hard: the generator always keeps it": "Задължително: генераторът винаги го спазва", + "Soft: the generator keeps it where it can": "Гъвкаво: генераторът го спазва, когато може", + "Weight (1 to 3)": "Тежест (от 1 до 3)", + "Most lessons a day": "Най-много часове на ден", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Учител (потребителско име)", + "Activity (group:subject)": "Дейност (група:предмет)", + "Say who or what the wish is about.": "Посочете за кого или за какво е желанието.", + "Choose at least one period.": "Изберете поне един час.", + "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", + "Timetable": "Разписание", + "Wishes": "Желания", + "Timetable wishes": "Желания за разписанието" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index e5d2ea1b..fd4bd0f5 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše časova dnevno", + "Period": "Čas", + "{day} period {number}": "{day} čas {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili šta se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan čas.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index d9872360..5e0c4b7d 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše časova dnevno", + "Period": "Čas", + "{day} period {number}": "{day} čas {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili šta se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan čas.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index d68871b0..ca9d46e6 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiu", "Why the run failed": "Per què ha fallat l'execució", "Published at": "Publicat el", - "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany" + "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany", + "Edit wish": "Edita el desig", + "New wish": "Desig nou", + "Hard: the generator always keeps it": "Estricte: el generador sempre el respecta", + "Soft: the generator keeps it where it can": "Flexible: el generador el respecta quan pot", + "Weight (1 to 3)": "Pes (d'1 a 3)", + "Most lessons a day": "Màxim de classes al dia", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docent (nom d'usuari)", + "Activity (group:subject)": "Activitat (grup:assignatura)", + "Say who or what the wish is about.": "Indiqueu a qui o a què es refereix el desig.", + "Choose at least one period.": "Trieu almenys una franja.", + "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", + "Timetable": "Horari", + "Wishes": "Desitjos", + "Timetable wishes": "Desitjos d'horari" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 0c8a732d..ca6f1406 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1007,7 +1007,23 @@ "Reason": "Motiu", "Why the run failed": "Per què ha fallat l'execució", "Published at": "Publicat el", - "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany" + "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany", + "Edit wish": "Edita el desig", + "New wish": "Desig nou", + "Hard: the generator always keeps it": "Estricte: el generador sempre el respecta", + "Soft: the generator keeps it where it can": "Flexible: el generador el respecta quan pot", + "Weight (1 to 3)": "Pes (d'1 a 3)", + "Most lessons a day": "Màxim de classes al dia", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docent (nom d'usuari)", + "Activity (group:subject)": "Activitat (grup:assignatura)", + "Say who or what the wish is about.": "Indiqueu a qui o a què es refereix el desig.", + "Choose at least one period.": "Trieu almenys una franja.", + "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", + "Timetable": "Horari", + "Wishes": "Desitjos", + "Timetable wishes": "Desitjos d'horari" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index dc0c2e0b..e270e46e 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Důvod", "Why the run failed": "Proč běh selhal", "Published at": "Zveřejněno", - "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin" + "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin", + "Edit wish": "Upravit přání", + "New wish": "Nové přání", + "Hard: the generator always keeps it": "Pevné: generátor je vždy dodrží", + "Soft: the generator keeps it where it can": "Měkké: generátor je dodrží, kde to jde", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Nejvýše hodin denně", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učitel (uživatelské jméno)", + "Activity (group:subject)": "Aktivita (skupina:předmět)", + "Say who or what the wish is about.": "Uveďte, koho nebo čeho se přání týká.", + "Choose at least one period.": "Vyberte alespoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", + "Timetable": "Rozvrh", + "Wishes": "Přání", + "Timetable wishes": "Přání k rozvrhu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 69c1006c..39c076c6 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1007,7 +1007,23 @@ "Reason": "Důvod", "Why the run failed": "Proč běh selhal", "Published at": "Zveřejněno", - "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin" + "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin", + "Edit wish": "Upravit přání", + "New wish": "Nové přání", + "Hard: the generator always keeps it": "Pevné: generátor je vždy dodrží", + "Soft: the generator keeps it where it can": "Měkké: generátor je dodrží, kde to jde", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Nejvýše hodin denně", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učitel (uživatelské jméno)", + "Activity (group:subject)": "Aktivita (skupina:předmět)", + "Say who or what the wish is about.": "Uveďte, koho nebo čeho se přání týká.", + "Choose at least one period.": "Vyberte alespoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", + "Timetable": "Rozvrh", + "Wishes": "Přání", + "Timetable wishes": "Přání k rozvrhu" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 3bbeff32..2749c71f 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Årsag", "Why the run failed": "Hvorfor kørslen mislykkedes", "Published at": "Udgivet", - "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner" + "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner", + "Edit wish": "Rediger ønske", + "New wish": "Nyt ønske", + "Hard: the generator always keeps it": "Hårdt: generatoren overholder det altid", + "Soft: the generator keeps it where it can": "Blødt: generatoren overholder det, hvor den kan", + "Weight (1 to 3)": "Vægt (1 til 3)", + "Most lessons a day": "Højst antal lektioner om dagen", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lærer (brugernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Angiv hvem eller hvad ønsket gælder.", + "Choose at least one period.": "Vælg mindst én lektion.", + "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", + "Timetable": "Skema", + "Wishes": "Ønsker", + "Timetable wishes": "Skemaønsker" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 0a6f81f2..3b49229a 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1007,7 +1007,23 @@ "Reason": "Årsag", "Why the run failed": "Hvorfor kørslen mislykkedes", "Published at": "Udgivet", - "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner" + "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner", + "Edit wish": "Rediger ønske", + "New wish": "Nyt ønske", + "Hard: the generator always keeps it": "Hårdt: generatoren overholder det altid", + "Soft: the generator keeps it where it can": "Blødt: generatoren overholder det, hvor den kan", + "Weight (1 to 3)": "Vægt (1 til 3)", + "Most lessons a day": "Højst antal lektioner om dagen", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lærer (brugernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Angiv hvem eller hvad ønsket gælder.", + "Choose at least one period.": "Vælg mindst én lektion.", + "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", + "Timetable": "Skema", + "Wishes": "Ønsker", + "Timetable wishes": "Skemaønsker" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index aefb9ccd..8d26d5a7 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Grund", "Why the run failed": "Warum der Lauf fehlschlug", "Published at": "Veröffentlicht am", - "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde" + "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde", + "Edit wish": "Wunsch bearbeiten", + "New wish": "Neuer Wunsch", + "Hard: the generator always keeps it": "Hart: der Generator hält ihn immer ein", + "Soft: the generator keeps it where it can": "Weich: der Generator hält ihn ein, wo er kann", + "Weight (1 to 3)": "Gewicht (1 bis 3)", + "Most lessons a day": "Höchstens Stunden pro Tag", + "Period": "Stunde", + "{day} period {number}": "{day} Stunde {number}", + "Teacher (user name)": "Lehrkraft (Benutzername)", + "Activity (group:subject)": "Aktivität (Gruppe:Fach)", + "Say who or what the wish is about.": "Geben Sie an, wen oder was der Wunsch betrifft.", + "Choose at least one period.": "Wählen Sie mindestens eine Stunde.", + "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", + "Timetable": "Stundenplan", + "Wishes": "Wünsche", + "Timetable wishes": "Stundenplanwünsche" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index eef9e8af..5fc84b4f 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1007,7 +1007,23 @@ "Reason": "Grund", "Why the run failed": "Warum der Lauf fehlschlug", "Published at": "Veröffentlicht am", - "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde" + "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde", + "Edit wish": "Wunsch bearbeiten", + "New wish": "Neuer Wunsch", + "Hard: the generator always keeps it": "Hart: der Generator hält ihn immer ein", + "Soft: the generator keeps it where it can": "Weich: der Generator hält ihn ein, wo er kann", + "Weight (1 to 3)": "Gewicht (1 bis 3)", + "Most lessons a day": "Höchstens Stunden pro Tag", + "Period": "Stunde", + "{day} period {number}": "{day} Stunde {number}", + "Teacher (user name)": "Lehrkraft (Benutzername)", + "Activity (group:subject)": "Aktivität (Gruppe:Fach)", + "Say who or what the wish is about.": "Geben Sie an, wen oder was der Wunsch betrifft.", + "Choose at least one period.": "Wählen Sie mindestens eine Stunde.", + "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", + "Timetable": "Stundenplan", + "Wishes": "Wünsche", + "Timetable wishes": "Stundenplanwünsche" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index a813d4d1..f963e68d 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Αιτία", "Why the run failed": "Γιατί απέτυχε η εκτέλεση", "Published at": "Δημοσιεύτηκε στις", - "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα" + "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα", + "Edit wish": "Επεξεργασία επιθυμίας", + "New wish": "Νέα επιθυμία", + "Hard: the generator always keeps it": "Υποχρεωτική: η γεννήτρια την τηρεί πάντα", + "Soft: the generator keeps it where it can": "Ευέλικτη: η γεννήτρια την τηρεί όπου μπορεί", + "Weight (1 to 3)": "Βάρος (1 έως 3)", + "Most lessons a day": "Το πολύ μαθήματα την ημέρα", + "Period": "Ώρα", + "{day} period {number}": "{day} ώρα {number}", + "Teacher (user name)": "Εκπαιδευτικός (όνομα χρήστη)", + "Activity (group:subject)": "Δραστηριότητα (ομάδα:μάθημα)", + "Say who or what the wish is about.": "Δηλώστε ποιον ή τι αφορά η επιθυμία.", + "Choose at least one period.": "Επιλέξτε τουλάχιστον μία ώρα.", + "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", + "Timetable": "Ωρολόγιο πρόγραμμα", + "Wishes": "Επιθυμίες", + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 6dd0518e..3c39475e 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1007,7 +1007,23 @@ "Reason": "Αιτία", "Why the run failed": "Γιατί απέτυχε η εκτέλεση", "Published at": "Δημοσιεύτηκε στις", - "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα" + "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα", + "Edit wish": "Επεξεργασία επιθυμίας", + "New wish": "Νέα επιθυμία", + "Hard: the generator always keeps it": "Υποχρεωτική: η γεννήτρια την τηρεί πάντα", + "Soft: the generator keeps it where it can": "Ευέλικτη: η γεννήτρια την τηρεί όπου μπορεί", + "Weight (1 to 3)": "Βάρος (1 έως 3)", + "Most lessons a day": "Το πολύ μαθήματα την ημέρα", + "Period": "Ώρα", + "{day} period {number}": "{day} ώρα {number}", + "Teacher (user name)": "Εκπαιδευτικός (όνομα χρήστη)", + "Activity (group:subject)": "Δραστηριότητα (ομάδα:μάθημα)", + "Say who or what the wish is about.": "Δηλώστε ποιον ή τι αφορά η επιθυμία.", + "Choose at least one period.": "Επιλέξτε τουλάχιστον μία ώρα.", + "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", + "Timetable": "Ωρολόγιο πρόγραμμα", + "Wishes": "Επιθυμίες", + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 6fe2c3ab..a1c7c783 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -986,7 +986,23 @@ OC.L10N.register( "Reason": "Reason", "Why the run failed": "Why the run failed", "Published at": "Published at", - "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons" + "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons", + "Edit wish": "Edit wish", + "New wish": "New wish", + "Hard: the generator always keeps it": "Hard: the generator always keeps it", + "Soft: the generator keeps it where it can": "Soft: the generator keeps it where it can", + "Weight (1 to 3)": "Weight (1 to 3)", + "Most lessons a day": "Most lessons a day", + "Period": "Period", + "{day} period {number}": "{day} period {number}", + "Teacher (user name)": "Teacher (user name)", + "Activity (group:subject)": "Activity (group:subject)", + "Say who or what the wish is about.": "Say who or what the wish is about.", + "Choose at least one period.": "Choose at least one period.", + "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", + "Timetable": "Timetable", + "Wishes": "Wishes", + "Timetable wishes": "Timetable wishes" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index f551fa56..f3a66683 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -985,7 +985,23 @@ "Reason": "Reason", "Why the run failed": "Why the run failed", "Published at": "Published at", - "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons" + "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons", + "Edit wish": "Edit wish", + "New wish": "New wish", + "Hard: the generator always keeps it": "Hard: the generator always keeps it", + "Soft: the generator keeps it where it can": "Soft: the generator keeps it where it can", + "Weight (1 to 3)": "Weight (1 to 3)", + "Most lessons a day": "Most lessons a day", + "Period": "Period", + "{day} period {number}": "{day} period {number}", + "Teacher (user name)": "Teacher (user name)", + "Activity (group:subject)": "Activity (group:subject)", + "Say who or what the wish is about.": "Say who or what the wish is about.", + "Choose at least one period.": "Choose at least one period.", + "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", + "Timetable": "Timetable", + "Wishes": "Wishes", + "Timetable wishes": "Timetable wishes" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index d809f625..d6a918db 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Por qué falló la ejecución", "Published at": "Publicado el", - "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador" + "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador", + "Edit wish": "Editar deseo", + "New wish": "Nuevo deseo", + "Hard: the generator always keeps it": "Estricto: el generador siempre lo respeta", + "Soft: the generator keeps it where it can": "Flexible: el generador lo respeta cuando puede", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de clases al día", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docente (nombre de usuario)", + "Activity (group:subject)": "Actividad (grupo:asignatura)", + "Say who or what the wish is about.": "Indique a quién o a qué se refiere el deseo.", + "Choose at least one period.": "Elija al menos una franja.", + "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", + "Timetable": "Horario", + "Wishes": "Deseos", + "Timetable wishes": "Deseos de horario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index b2aa9898..8e0a49e6 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Por qué falló la ejecución", "Published at": "Publicado el", - "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador" + "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador", + "Edit wish": "Editar deseo", + "New wish": "Nuevo deseo", + "Hard: the generator always keeps it": "Estricto: el generador siempre lo respeta", + "Soft: the generator keeps it where it can": "Flexible: el generador lo respeta cuando puede", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de clases al día", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docente (nombre de usuario)", + "Activity (group:subject)": "Actividad (grupo:asignatura)", + "Say who or what the wish is about.": "Indique a quién o a qué se refiere el deseo.", + "Choose at least one period.": "Elija al menos una franja.", + "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", + "Timetable": "Horario", + "Wishes": "Deseos", + "Timetable wishes": "Deseos de horario" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index cf714176..90b2e52d 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Põhjus", "Why the run failed": "Miks käivitus ebaõnnestus", "Published at": "Avaldatud", - "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks" + "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks", + "Edit wish": "Muuda soovi", + "New wish": "Uus soov", + "Hard: the generator always keeps it": "Kohustuslik: generaator peab sellest alati kinni", + "Soft: the generator keeps it where it can": "Paindlik: generaator peab sellest kinni, kui saab", + "Weight (1 to 3)": "Kaal (1 kuni 3)", + "Most lessons a day": "Kõige rohkem tunde päevas", + "Period": "Tund", + "{day} period {number}": "{day} tund {number}", + "Teacher (user name)": "Õpetaja (kasutajanimi)", + "Activity (group:subject)": "Tegevus (rühm:aine)", + "Say who or what the wish is about.": "Märkige, kelle või mille kohta soov on.", + "Choose at least one period.": "Valige vähemalt üks tund.", + "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", + "Timetable": "Tunniplaan", + "Wishes": "Soovid", + "Timetable wishes": "Tunniplaani soovid" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 132f4156..fe6c19ba 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1007,7 +1007,23 @@ "Reason": "Põhjus", "Why the run failed": "Miks käivitus ebaõnnestus", "Published at": "Avaldatud", - "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks" + "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks", + "Edit wish": "Muuda soovi", + "New wish": "Uus soov", + "Hard: the generator always keeps it": "Kohustuslik: generaator peab sellest alati kinni", + "Soft: the generator keeps it where it can": "Paindlik: generaator peab sellest kinni, kui saab", + "Weight (1 to 3)": "Kaal (1 kuni 3)", + "Most lessons a day": "Kõige rohkem tunde päevas", + "Period": "Tund", + "{day} period {number}": "{day} tund {number}", + "Teacher (user name)": "Õpetaja (kasutajanimi)", + "Activity (group:subject)": "Tegevus (rühm:aine)", + "Say who or what the wish is about.": "Märkige, kelle või mille kohta soov on.", + "Choose at least one period.": "Valige vähemalt üks tund.", + "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", + "Timetable": "Tunniplaan", + "Wishes": "Soovid", + "Timetable wishes": "Tunniplaani soovid" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 007249c5..b43ba315 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Syy", "Why the run failed": "Miksi ajo epäonnistui", "Published at": "Julkaistu", - "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi" + "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi", + "Edit wish": "Muokkaa toivetta", + "New wish": "Uusi toive", + "Hard: the generator always keeps it": "Ehdoton: generaattori noudattaa sitä aina", + "Soft: the generator keeps it where it can": "Joustava: generaattori noudattaa sitä, kun voi", + "Weight (1 to 3)": "Paino (1-3)", + "Most lessons a day": "Enintään tunteja päivässä", + "Period": "Tunti", + "{day} period {number}": "{day} tunti {number}", + "Teacher (user name)": "Opettaja (käyttäjänimi)", + "Activity (group:subject)": "Toiminta (ryhmä:aine)", + "Say who or what the wish is about.": "Kerro, ketä tai mitä toive koskee.", + "Choose at least one period.": "Valitse vähintään yksi tunti.", + "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", + "Timetable": "Lukujärjestys", + "Wishes": "Toiveet", + "Timetable wishes": "Lukujärjestystoiveet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 888d9764..4bb5cc6d 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1007,7 +1007,23 @@ "Reason": "Syy", "Why the run failed": "Miksi ajo epäonnistui", "Published at": "Julkaistu", - "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi" + "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi", + "Edit wish": "Muokkaa toivetta", + "New wish": "Uusi toive", + "Hard: the generator always keeps it": "Ehdoton: generaattori noudattaa sitä aina", + "Soft: the generator keeps it where it can": "Joustava: generaattori noudattaa sitä, kun voi", + "Weight (1 to 3)": "Paino (1-3)", + "Most lessons a day": "Enintään tunteja päivässä", + "Period": "Tunti", + "{day} period {number}": "{day} tunti {number}", + "Teacher (user name)": "Opettaja (käyttäjänimi)", + "Activity (group:subject)": "Toiminta (ryhmä:aine)", + "Say who or what the wish is about.": "Kerro, ketä tai mitä toive koskee.", + "Choose at least one period.": "Valitse vähintään yksi tunti.", + "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", + "Timetable": "Lukujärjestys", + "Wishes": "Toiveet", + "Timetable wishes": "Lukujärjestystoiveet" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 73238b14..196a2085 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Raison", "Why the run failed": "Pourquoi le calcul a échoué", "Published at": "Publié le", - "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons" + "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons", + "Edit wish": "Modifier le souhait", + "New wish": "Nouveau souhait", + "Hard: the generator always keeps it": "Strict : le générateur le respecte toujours", + "Soft: the generator keeps it where it can": "Souple : le générateur le respecte quand il le peut", + "Weight (1 to 3)": "Poids (1 à 3)", + "Most lessons a day": "Nombre maximal de cours par jour", + "Period": "Créneau", + "{day} period {number}": "{day} créneau {number}", + "Teacher (user name)": "Enseignant (nom d'utilisateur)", + "Activity (group:subject)": "Activité (groupe:matière)", + "Say who or what the wish is about.": "Indiquez qui ou quoi concerne le souhait.", + "Choose at least one period.": "Choisissez au moins un créneau.", + "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", + "Timetable": "Emploi du temps", + "Wishes": "Souhaits", + "Timetable wishes": "Souhaits d'emploi du temps" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 1f7b947f..2b668abd 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1007,7 +1007,23 @@ "Reason": "Raison", "Why the run failed": "Pourquoi le calcul a échoué", "Published at": "Publié le", - "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons" + "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons", + "Edit wish": "Modifier le souhait", + "New wish": "Nouveau souhait", + "Hard: the generator always keeps it": "Strict : le générateur le respecte toujours", + "Soft: the generator keeps it where it can": "Souple : le générateur le respecte quand il le peut", + "Weight (1 to 3)": "Poids (1 à 3)", + "Most lessons a day": "Nombre maximal de cours par jour", + "Period": "Créneau", + "{day} period {number}": "{day} créneau {number}", + "Teacher (user name)": "Enseignant (nom d'utilisateur)", + "Activity (group:subject)": "Activité (groupe:matière)", + "Say who or what the wish is about.": "Indiquez qui ou quoi concerne le souhait.", + "Choose at least one period.": "Choisissez au moins un créneau.", + "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", + "Timetable": "Emploi du temps", + "Wishes": "Souhaits", + "Timetable wishes": "Souhaits d'emploi du temps" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 8f460dfb..684e90ee 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Cúis", "Why the run failed": "Cén fáth ar theip ar an rith", "Published at": "Foilsithe ar", - "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna" + "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna", + "Edit wish": "Cuir mian in eagar", + "New wish": "Mian nua", + "Hard: the generator always keeps it": "Docht: coinníonn an gineadóir i gcónaí é", + "Soft: the generator keeps it where it can": "Bog: coinníonn an gineadóir é nuair is féidir", + "Weight (1 to 3)": "Meáchan (1 go 3)", + "Most lessons a day": "Uasmhéid ceachtanna in aghaidh an lae", + "Period": "Tréimhse", + "{day} period {number}": "{day} tréimhse {number}", + "Teacher (user name)": "Múinteoir (ainm úsáideora)", + "Activity (group:subject)": "Gníomhaíocht (grúpa:ábhar)", + "Say who or what the wish is about.": "Luaigh cé nó cad lena mbaineann an mian.", + "Choose at least one period.": "Roghnaigh tréimhse amháin ar a laghad.", + "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", + "Timetable": "Clár ama", + "Wishes": "Mianta", + "Timetable wishes": "Mianta an chláir ama" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index d0a50576..c647eb2b 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1007,7 +1007,23 @@ "Reason": "Cúis", "Why the run failed": "Cén fáth ar theip ar an rith", "Published at": "Foilsithe ar", - "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna" + "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna", + "Edit wish": "Cuir mian in eagar", + "New wish": "Mian nua", + "Hard: the generator always keeps it": "Docht: coinníonn an gineadóir i gcónaí é", + "Soft: the generator keeps it where it can": "Bog: coinníonn an gineadóir é nuair is féidir", + "Weight (1 to 3)": "Meáchan (1 go 3)", + "Most lessons a day": "Uasmhéid ceachtanna in aghaidh an lae", + "Period": "Tréimhse", + "{day} period {number}": "{day} tréimhse {number}", + "Teacher (user name)": "Múinteoir (ainm úsáideora)", + "Activity (group:subject)": "Gníomhaíocht (grúpa:ábhar)", + "Say who or what the wish is about.": "Luaigh cé nó cad lena mbaineann an mian.", + "Choose at least one period.": "Roghnaigh tréimhse amháin ar a laghad.", + "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", + "Timetable": "Clár ama", + "Wishes": "Mianta", + "Timetable wishes": "Mianta an chláir ama" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 22c6c27e..81d77966 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše sati dnevno", + "Period": "Sat", + "{day} period {number}": "{day} sat {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili što se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan sat.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index 62a8b373..c291b044 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše sati dnevno", + "Period": "Sat", + "{day} period {number}": "{day} sat {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili što se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan sat.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 418cb9f0..120b3e27 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Ok", "Why the run failed": "Miért volt sikertelen a futás", "Published at": "Közzétéve", - "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként" + "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként", + "Edit wish": "Kívánság szerkesztése", + "New wish": "Új kívánság", + "Hard: the generator always keeps it": "Kötelező: a generátor mindig betartja", + "Soft: the generator keeps it where it can": "Rugalmas: a generátor betartja, ahol tudja", + "Weight (1 to 3)": "Súly (1-től 3-ig)", + "Most lessons a day": "Legfeljebb ennyi óra naponta", + "Period": "Óra", + "{day} period {number}": "{day} {number}. óra", + "Teacher (user name)": "Tanár (felhasználónév)", + "Activity (group:subject)": "Tevékenység (csoport:tantárgy)", + "Say who or what the wish is about.": "Adja meg, kire vagy mire vonatkozik a kívánság.", + "Choose at least one period.": "Válasszon legalább egy órát.", + "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", + "Timetable": "Órarend", + "Wishes": "Kívánságok", + "Timetable wishes": "Órarendi kívánságok" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 702e783e..2d1c09e6 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1007,7 +1007,23 @@ "Reason": "Ok", "Why the run failed": "Miért volt sikertelen a futás", "Published at": "Közzétéve", - "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként" + "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként", + "Edit wish": "Kívánság szerkesztése", + "New wish": "Új kívánság", + "Hard: the generator always keeps it": "Kötelező: a generátor mindig betartja", + "Soft: the generator keeps it where it can": "Rugalmas: a generátor betartja, ahol tudja", + "Weight (1 to 3)": "Súly (1-től 3-ig)", + "Most lessons a day": "Legfeljebb ennyi óra naponta", + "Period": "Óra", + "{day} period {number}": "{day} {number}. óra", + "Teacher (user name)": "Tanár (felhasználónév)", + "Activity (group:subject)": "Tevékenység (csoport:tantárgy)", + "Say who or what the wish is about.": "Adja meg, kire vagy mire vonatkozik a kívánság.", + "Choose at least one period.": "Válasszon legalább egy órát.", + "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", + "Timetable": "Órarend", + "Wishes": "Kívánságok", + "Timetable wishes": "Órarendi kívánságok" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 0bf5e511..d5dd82b2 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Ástæða", "Why the run failed": "Hvers vegna keyrslan mistókst", "Published at": "Birt", - "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum" + "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum", + "Edit wish": "Breyta ósk", + "New wish": "Ný ósk", + "Hard: the generator always keeps it": "Föst: rafallinn virðir hana alltaf", + "Soft: the generator keeps it where it can": "Sveigjanleg: rafallinn virðir hana þar sem hann getur", + "Weight (1 to 3)": "Vægi (1 til 3)", + "Most lessons a day": "Mest kennslustundir á dag", + "Period": "Kennslustund", + "{day} period {number}": "{day} kennslustund {number}", + "Teacher (user name)": "Kennari (notandanafn)", + "Activity (group:subject)": "Verkefni (hópur:fag)", + "Say who or what the wish is about.": "Tilgreindu hvern eða hvað óskin varðar.", + "Choose at least one period.": "Veldu að minnsta kosti eina kennslustund.", + "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", + "Timetable": "Stundaskrá", + "Wishes": "Óskir", + "Timetable wishes": "Óskir um stundaskrá" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 14315c2b..695953ce 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1007,7 +1007,23 @@ "Reason": "Ástæða", "Why the run failed": "Hvers vegna keyrslan mistókst", "Published at": "Birt", - "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum" + "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum", + "Edit wish": "Breyta ósk", + "New wish": "Ný ósk", + "Hard: the generator always keeps it": "Föst: rafallinn virðir hana alltaf", + "Soft: the generator keeps it where it can": "Sveigjanleg: rafallinn virðir hana þar sem hann getur", + "Weight (1 to 3)": "Vægi (1 til 3)", + "Most lessons a day": "Mest kennslustundir á dag", + "Period": "Kennslustund", + "{day} period {number}": "{day} kennslustund {number}", + "Teacher (user name)": "Kennari (notandanafn)", + "Activity (group:subject)": "Verkefni (hópur:fag)", + "Say who or what the wish is about.": "Tilgreindu hvern eða hvað óskin varðar.", + "Choose at least one period.": "Veldu að minnsta kosti eina kennslustund.", + "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", + "Timetable": "Stundaskrá", + "Wishes": "Óskir", + "Timetable wishes": "Óskir um stundaskrá" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index 28438919..84f734c1 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Perché l'esecuzione è fallita", "Published at": "Pubblicato il", - "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza" + "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza", + "Edit wish": "Modifica desiderio", + "New wish": "Nuovo desiderio", + "Hard: the generator always keeps it": "Vincolante: il generatore lo rispetta sempre", + "Soft: the generator keeps it where it can": "Flessibile: il generatore lo rispetta quando può", + "Weight (1 to 3)": "Peso (da 1 a 3)", + "Most lessons a day": "Massimo di lezioni al giorno", + "Period": "Ora", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Docente (nome utente)", + "Activity (group:subject)": "Attività (gruppo:materia)", + "Say who or what the wish is about.": "Indica chi o cosa riguarda il desiderio.", + "Choose at least one period.": "Scegli almeno un'ora.", + "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", + "Timetable": "Orario", + "Wishes": "Desideri", + "Timetable wishes": "Desideri per l'orario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 4d83da62..8a398fd3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Perché l'esecuzione è fallita", "Published at": "Pubblicato il", - "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza" + "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza", + "Edit wish": "Modifica desiderio", + "New wish": "Nuovo desiderio", + "Hard: the generator always keeps it": "Vincolante: il generatore lo rispetta sempre", + "Soft: the generator keeps it where it can": "Flessibile: il generatore lo rispetta quando può", + "Weight (1 to 3)": "Peso (da 1 a 3)", + "Most lessons a day": "Massimo di lezioni al giorno", + "Period": "Ora", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Docente (nome utente)", + "Activity (group:subject)": "Attività (gruppo:materia)", + "Say who or what the wish is about.": "Indica chi o cosa riguarda il desiderio.", + "Choose at least one period.": "Scegli almeno un'ora.", + "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", + "Timetable": "Orario", + "Wishes": "Desideri", + "Timetable wishes": "Desideri per l'orario" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 056e0794..65a51e67 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Grond", "Why the run failed": "Firwat de Laf feelgeschloen ass", "Published at": "Publizéiert den", - "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf" + "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf", + "Edit wish": "Wonsch änneren", + "New wish": "Neie Wonsch", + "Hard: the generator always keeps it": "Fix: de Generator hält sech ëmmer drun", + "Soft: the generator keeps it where it can": "Flexibel: de Generator hält sech drun, wou e kann", + "Weight (1 to 3)": "Gewiicht (1 bis 3)", + "Most lessons a day": "Héchstens Stonnen den Dag", + "Period": "Stonn", + "{day} period {number}": "{day} Stonn {number}", + "Teacher (user name)": "Enseignant (Benotzernumm)", + "Activity (group:subject)": "Aktivitéit (Grupp:Fach)", + "Say who or what the wish is about.": "Gitt un, wien oder wat de Wonsch betrëfft.", + "Choose at least one period.": "Wielt mindestens eng Stonn.", + "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", + "Timetable": "Stonneplang", + "Wishes": "Wënsch", + "Timetable wishes": "Stonneplangwënsch" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 693cb28d..6a87037e 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1007,7 +1007,23 @@ "Reason": "Grond", "Why the run failed": "Firwat de Laf feelgeschloen ass", "Published at": "Publizéiert den", - "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf" + "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf", + "Edit wish": "Wonsch änneren", + "New wish": "Neie Wonsch", + "Hard: the generator always keeps it": "Fix: de Generator hält sech ëmmer drun", + "Soft: the generator keeps it where it can": "Flexibel: de Generator hält sech drun, wou e kann", + "Weight (1 to 3)": "Gewiicht (1 bis 3)", + "Most lessons a day": "Héchstens Stonnen den Dag", + "Period": "Stonn", + "{day} period {number}": "{day} Stonn {number}", + "Teacher (user name)": "Enseignant (Benotzernumm)", + "Activity (group:subject)": "Aktivitéit (Grupp:Fach)", + "Say who or what the wish is about.": "Gitt un, wien oder wat de Wonsch betrëfft.", + "Choose at least one period.": "Wielt mindestens eng Stonn.", + "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", + "Timetable": "Stonneplang", + "Wishes": "Wënsch", + "Timetable wishes": "Stonneplangwënsch" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 167a2c82..423106ef 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Priežastis", "Why the run failed": "Kodėl paleidimas nepavyko", "Published at": "Paskelbta", - "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos" + "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos", + "Edit wish": "Redaguoti pageidavimą", + "New wish": "Naujas pageidavimas", + "Hard: the generator always keeps it": "Privalomas: generatorius jo visada laikosi", + "Soft: the generator keeps it where it can": "Lankstus: generatorius jo laikosi, kai gali", + "Weight (1 to 3)": "Svoris (nuo 1 iki 3)", + "Most lessons a day": "Daugiausia pamokų per dieną", + "Period": "Pamoka", + "{day} period {number}": "{day} {number} pamoka", + "Teacher (user name)": "Mokytojas (naudotojo vardas)", + "Activity (group:subject)": "Veikla (grupė:dalykas)", + "Say who or what the wish is about.": "Nurodykite, ko pageidavimas liečia.", + "Choose at least one period.": "Pasirinkite bent vieną pamoką.", + "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", + "Timetable": "Tvarkaraštis", + "Wishes": "Pageidavimai", + "Timetable wishes": "Tvarkaraščio pageidavimai" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index a9f1daad..2cc512b0 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1007,7 +1007,23 @@ "Reason": "Priežastis", "Why the run failed": "Kodėl paleidimas nepavyko", "Published at": "Paskelbta", - "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos" + "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos", + "Edit wish": "Redaguoti pageidavimą", + "New wish": "Naujas pageidavimas", + "Hard: the generator always keeps it": "Privalomas: generatorius jo visada laikosi", + "Soft: the generator keeps it where it can": "Lankstus: generatorius jo laikosi, kai gali", + "Weight (1 to 3)": "Svoris (nuo 1 iki 3)", + "Most lessons a day": "Daugiausia pamokų per dieną", + "Period": "Pamoka", + "{day} period {number}": "{day} {number} pamoka", + "Teacher (user name)": "Mokytojas (naudotojo vardas)", + "Activity (group:subject)": "Veikla (grupė:dalykas)", + "Say who or what the wish is about.": "Nurodykite, ko pageidavimas liečia.", + "Choose at least one period.": "Pasirinkite bent vieną pamoką.", + "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", + "Timetable": "Tvarkaraštis", + "Wishes": "Pageidavimai", + "Timetable wishes": "Tvarkaraščio pageidavimai" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index a049989b..0c631d2d 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Iemesls", "Why the run failed": "Kāpēc palaišana neizdevās", "Published at": "Publicēts", - "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas" + "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas", + "Edit wish": "Rediģēt vēlmi", + "New wish": "Jauna vēlme", + "Hard: the generator always keeps it": "Obligāta: ģenerators to vienmēr ievēro", + "Soft: the generator keeps it where it can": "Elastīga: ģenerators to ievēro, kur var", + "Weight (1 to 3)": "Svars (no 1 līdz 3)", + "Most lessons a day": "Ne vairāk stundu dienā", + "Period": "Stunda", + "{day} period {number}": "{day} {number}. stunda", + "Teacher (user name)": "Skolotājs (lietotājvārds)", + "Activity (group:subject)": "Aktivitāte (grupa:priekšmets)", + "Say who or what the wish is about.": "Norādiet, uz ko vēlme attiecas.", + "Choose at least one period.": "Izvēlieties vismaz vienu stundu.", + "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", + "Timetable": "Stundu saraksts", + "Wishes": "Vēlmes", + "Timetable wishes": "Stundu saraksta vēlmes" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index e253cd7b..0804406e 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1007,7 +1007,23 @@ "Reason": "Iemesls", "Why the run failed": "Kāpēc palaišana neizdevās", "Published at": "Publicēts", - "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas" + "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas", + "Edit wish": "Rediģēt vēlmi", + "New wish": "Jauna vēlme", + "Hard: the generator always keeps it": "Obligāta: ģenerators to vienmēr ievēro", + "Soft: the generator keeps it where it can": "Elastīga: ģenerators to ievēro, kur var", + "Weight (1 to 3)": "Svars (no 1 līdz 3)", + "Most lessons a day": "Ne vairāk stundu dienā", + "Period": "Stunda", + "{day} period {number}": "{day} {number}. stunda", + "Teacher (user name)": "Skolotājs (lietotājvārds)", + "Activity (group:subject)": "Aktivitāte (grupa:priekšmets)", + "Say who or what the wish is about.": "Norādiet, uz ko vēlme attiecas.", + "Choose at least one period.": "Izvēlieties vismaz vienu stundu.", + "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", + "Timetable": "Stundu saraksts", + "Wishes": "Vēlmes", + "Timetable wishes": "Stundu saraksta vēlmes" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 8cec77f8..a7a889cd 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Зошто извршувањето не успеа", "Published at": "Објавено на", - "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови" + "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови", + "Edit wish": "Уреди желба", + "New wish": "Нова желба", + "Hard: the generator always keeps it": "Задолжителна: генераторот секогаш ја почитува", + "Soft: the generator keeps it where it can": "Флексибилна: генераторот ја почитува каде што може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Најмногу часови дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведете на кого или на што се однесува желбата.", + "Choose at least one period.": "Изберете барем еден час.", + "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", + "Timetable": "Распоред", + "Wishes": "Желби", + "Timetable wishes": "Желби за распоредот" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 9575a935..11492adf 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Зошто извршувањето не успеа", "Published at": "Објавено на", - "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови" + "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови", + "Edit wish": "Уреди желба", + "New wish": "Нова желба", + "Hard: the generator always keeps it": "Задолжителна: генераторот секогаш ја почитува", + "Soft: the generator keeps it where it can": "Флексибилна: генераторот ја почитува каде што може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Најмногу часови дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведете на кого или на што се однесува желбата.", + "Choose at least one period.": "Изберете барем еден час.", + "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", + "Timetable": "Распоред", + "Wishes": "Желби", + "Timetable wishes": "Желби за распоредот" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index ea5a2717..b965074f 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Raġuni", "Why the run failed": "Għaliex falliet il-ġirja", "Published at": "Ippubblikat fi", - "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz" + "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz", + "Edit wish": "Editja x-xewqa", + "New wish": "Xewqa ġdida", + "Hard: the generator always keeps it": "Iebsa: il-ġeneratur dejjem iżommha", + "Soft: the generator keeps it where it can": "Ratba: il-ġeneratur iżommha fejn jista'", + "Weight (1 to 3)": "Piż (minn 1 sa 3)", + "Most lessons a day": "L-akbar numru ta' lezzjonijiet kuljum", + "Period": "Perjodu", + "{day} period {number}": "{day} perjodu {number}", + "Teacher (user name)": "Għalliem (isem tal-utent)", + "Activity (group:subject)": "Attività (grupp:suġġett)", + "Say who or what the wish is about.": "Għid dwar min jew dwar xiex hi x-xewqa.", + "Choose at least one period.": "Agħżel mill-inqas perjodu wieħed.", + "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", + "Timetable": "Orarju", + "Wishes": "Xewqat", + "Timetable wishes": "Xewqat għall-orarju" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 59ef6162..449db2a0 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1007,7 +1007,23 @@ "Reason": "Raġuni", "Why the run failed": "Għaliex falliet il-ġirja", "Published at": "Ippubblikat fi", - "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz" + "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz", + "Edit wish": "Editja x-xewqa", + "New wish": "Xewqa ġdida", + "Hard: the generator always keeps it": "Iebsa: il-ġeneratur dejjem iżommha", + "Soft: the generator keeps it where it can": "Ratba: il-ġeneratur iżommha fejn jista'", + "Weight (1 to 3)": "Piż (minn 1 sa 3)", + "Most lessons a day": "L-akbar numru ta' lezzjonijiet kuljum", + "Period": "Perjodu", + "{day} period {number}": "{day} perjodu {number}", + "Teacher (user name)": "Għalliem (isem tal-utent)", + "Activity (group:subject)": "Attività (grupp:suġġett)", + "Say who or what the wish is about.": "Għid dwar min jew dwar xiex hi x-xewqa.", + "Choose at least one period.": "Agħżel mill-inqas perjodu wieħed.", + "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", + "Timetable": "Orarju", + "Wishes": "Xewqat", + "Timetable wishes": "Xewqat għall-orarju" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index 6a42a851..ba08420d 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Årsak", "Why the run failed": "Hvorfor kjøringen mislyktes", "Published at": "Publisert", - "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer" + "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer", + "Edit wish": "Rediger ønske", + "New wish": "Nytt ønske", + "Hard: the generator always keeps it": "Hardt: generatoren følger det alltid", + "Soft: the generator keeps it where it can": "Mykt: generatoren følger det der den kan", + "Weight (1 to 3)": "Vekt (1 til 3)", + "Most lessons a day": "Høyst antall timer per dag", + "Period": "Time", + "{day} period {number}": "{day} time {number}", + "Teacher (user name)": "Lærer (brukernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Oppgi hvem eller hva ønsket gjelder.", + "Choose at least one period.": "Velg minst én time.", + "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", + "Timetable": "Timeplan", + "Wishes": "Ønsker", + "Timetable wishes": "Timeplanønsker" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 41ee8836..6315a70d 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1007,7 +1007,23 @@ "Reason": "Årsak", "Why the run failed": "Hvorfor kjøringen mislyktes", "Published at": "Publisert", - "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer" + "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer", + "Edit wish": "Rediger ønske", + "New wish": "Nytt ønske", + "Hard: the generator always keeps it": "Hardt: generatoren følger det alltid", + "Soft: the generator keeps it where it can": "Mykt: generatoren følger det der den kan", + "Weight (1 to 3)": "Vekt (1 til 3)", + "Most lessons a day": "Høyst antall timer per dag", + "Period": "Time", + "{day} period {number}": "{day} time {number}", + "Teacher (user name)": "Lærer (brukernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Oppgi hvem eller hva ønsket gjelder.", + "Choose at least one period.": "Velg minst én time.", + "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", + "Timetable": "Timeplan", + "Wishes": "Ønsker", + "Timetable wishes": "Timeplanønsker" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 0b0655df..5b104625 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1021,7 +1021,23 @@ OC.L10N.register( "Reason": "Reden", "Why the run failed": "Waarom de run mislukte", "Published at": "Gepubliceerd op", - "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven" + "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven", + "Edit wish": "Wens bewerken", + "New wish": "Nieuwe wens", + "Hard: the generator always keeps it": "Hard: de generator houdt zich er altijd aan", + "Soft: the generator keeps it where it can": "Zacht: de generator houdt zich eraan waar het kan", + "Weight (1 to 3)": "Gewicht (1 tot 3)", + "Most lessons a day": "Hoogstens aantal lessen per dag", + "Period": "Lesuur", + "{day} period {number}": "{day} lesuur {number}", + "Teacher (user name)": "Docent (gebruikersnaam)", + "Activity (group:subject)": "Activiteit (groep:vak)", + "Say who or what the wish is about.": "Geef aan over wie of wat de wens gaat.", + "Choose at least one period.": "Kies minstens één lesuur.", + "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", + "Timetable": "Rooster", + "Wishes": "Wensen", + "Timetable wishes": "Roosterwensen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index e9849877..188ed3c1 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1020,7 +1020,23 @@ "Reason": "Reden", "Why the run failed": "Waarom de run mislukte", "Published at": "Gepubliceerd op", - "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven" + "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven", + "Edit wish": "Wens bewerken", + "New wish": "Nieuwe wens", + "Hard: the generator always keeps it": "Hard: de generator houdt zich er altijd aan", + "Soft: the generator keeps it where it can": "Zacht: de generator houdt zich eraan waar het kan", + "Weight (1 to 3)": "Gewicht (1 tot 3)", + "Most lessons a day": "Hoogstens aantal lessen per dag", + "Period": "Lesuur", + "{day} period {number}": "{day} lesuur {number}", + "Teacher (user name)": "Docent (gebruikersnaam)", + "Activity (group:subject)": "Activiteit (groep:vak)", + "Say who or what the wish is about.": "Geef aan over wie of wat de wens gaat.", + "Choose at least one period.": "Kies minstens één lesuur.", + "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", + "Timetable": "Rooster", + "Wishes": "Wensen", + "Timetable wishes": "Roosterwensen" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 757cb92b..20d86374 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Powód", "Why the run failed": "Dlaczego przebieg się nie udał", "Published at": "Opublikowano", - "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze" + "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze", + "Edit wish": "Edytuj życzenie", + "New wish": "Nowe życzenie", + "Hard: the generator always keeps it": "Twarde: generator zawsze go przestrzega", + "Soft: the generator keeps it where it can": "Miękkie: generator przestrzega go, gdy może", + "Weight (1 to 3)": "Waga (od 1 do 3)", + "Most lessons a day": "Najwyżej lekcji dziennie", + "Period": "Lekcja", + "{day} period {number}": "{day} lekcja {number}", + "Teacher (user name)": "Nauczyciel (nazwa użytkownika)", + "Activity (group:subject)": "Zajęcia (grupa:przedmiot)", + "Say who or what the wish is about.": "Podaj, kogo lub czego dotyczy życzenie.", + "Choose at least one period.": "Wybierz co najmniej jedną lekcję.", + "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", + "Timetable": "Plan lekcji", + "Wishes": "Życzenia", + "Timetable wishes": "Życzenia do planu lekcji" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index e6099d88..b3f28009 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1007,7 +1007,23 @@ "Reason": "Powód", "Why the run failed": "Dlaczego przebieg się nie udał", "Published at": "Opublikowano", - "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze" + "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze", + "Edit wish": "Edytuj życzenie", + "New wish": "Nowe życzenie", + "Hard: the generator always keeps it": "Twarde: generator zawsze go przestrzega", + "Soft: the generator keeps it where it can": "Miękkie: generator przestrzega go, gdy może", + "Weight (1 to 3)": "Waga (od 1 do 3)", + "Most lessons a day": "Najwyżej lekcji dziennie", + "Period": "Lekcja", + "{day} period {number}": "{day} lekcja {number}", + "Teacher (user name)": "Nauczyciel (nazwa użytkownika)", + "Activity (group:subject)": "Zajęcia (grupa:przedmiot)", + "Say who or what the wish is about.": "Podaj, kogo lub czego dotyczy życzenie.", + "Choose at least one period.": "Wybierz co najmniej jedną lekcję.", + "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", + "Timetable": "Plan lekcji", + "Wishes": "Życzenia", + "Timetable wishes": "Życzenia do planu lekcji" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index fc500900..5d038eaf 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Porque falhou a execução", "Published at": "Publicado em", - "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho" + "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho", + "Edit wish": "Editar desejo", + "New wish": "Novo desejo", + "Hard: the generator always keeps it": "Rígido: o gerador cumpre-o sempre", + "Soft: the generator keeps it where it can": "Flexível: o gerador cumpre-o quando pode", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de aulas por dia", + "Period": "Tempo", + "{day} period {number}": "{day} tempo {number}", + "Teacher (user name)": "Professor (nome de utilizador)", + "Activity (group:subject)": "Atividade (grupo:disciplina)", + "Say who or what the wish is about.": "Indique a quem ou a quê se refere o desejo.", + "Choose at least one period.": "Escolha pelo menos um tempo.", + "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", + "Timetable": "Horário", + "Wishes": "Desejos", + "Timetable wishes": "Desejos de horário" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 1d2ff8bc..d78a0e90 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Porque falhou a execução", "Published at": "Publicado em", - "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho" + "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho", + "Edit wish": "Editar desejo", + "New wish": "Novo desejo", + "Hard: the generator always keeps it": "Rígido: o gerador cumpre-o sempre", + "Soft: the generator keeps it where it can": "Flexível: o gerador cumpre-o quando pode", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de aulas por dia", + "Period": "Tempo", + "{day} period {number}": "{day} tempo {number}", + "Teacher (user name)": "Professor (nome de utilizador)", + "Activity (group:subject)": "Atividade (grupo:disciplina)", + "Say who or what the wish is about.": "Indique a quem ou a quê se refere o desejo.", + "Choose at least one period.": "Escolha pelo menos um tempo.", + "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", + "Timetable": "Horário", + "Wishes": "Desejos", + "Timetable wishes": "Desejos de horário" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 891db5ad..39114072 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiv", "Why the run failed": "Pertge ch'il currer è fallì", "Published at": "Publitgà ils", - "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz" + "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz", + "Edit wish": "Modifitgar il giavisch", + "New wish": "Nov giavisch", + "Hard: the generator always keeps it": "Obligatoric: il generatur al resguarda adina", + "Soft: the generator keeps it where it can": "Flexibel: il generatur al resguarda nua ch'el po", + "Weight (1 to 3)": "Paisa (1 fin 3)", + "Most lessons a day": "Il pli bler lecziuns per di", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Magister (num d'utilisader)", + "Activity (group:subject)": "Activitad (gruppa:rom)", + "Say who or what the wish is about.": "Inditgai per tgi u tge ch'il giavisch vala.", + "Choose at least one period.": "Tscherni almain ina ura.", + "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", + "Timetable": "Plan d'uras", + "Wishes": "Giavischs", + "Timetable wishes": "Giavischs per il plan d'uras" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 92522b85..6f85826f 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1007,7 +1007,23 @@ "Reason": "Motiv", "Why the run failed": "Pertge ch'il currer è fallì", "Published at": "Publitgà ils", - "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz" + "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz", + "Edit wish": "Modifitgar il giavisch", + "New wish": "Nov giavisch", + "Hard: the generator always keeps it": "Obligatoric: il generatur al resguarda adina", + "Soft: the generator keeps it where it can": "Flexibel: il generatur al resguarda nua ch'el po", + "Weight (1 to 3)": "Paisa (1 fin 3)", + "Most lessons a day": "Il pli bler lecziuns per di", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Magister (num d'utilisader)", + "Activity (group:subject)": "Activitad (gruppa:rom)", + "Say who or what the wish is about.": "Inditgai per tgi u tge ch'il giavisch vala.", + "Choose at least one period.": "Tscherni almain ina ura.", + "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", + "Timetable": "Plan d'uras", + "Wishes": "Giavischs", + "Timetable wishes": "Giavischs per il plan d'uras" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 750b55dc..8fdfc0f5 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiv", "Why the run failed": "De ce a eșuat rularea", "Published at": "Publicat la", - "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă" + "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă", + "Edit wish": "Editează dorința", + "New wish": "Dorință nouă", + "Hard: the generator always keeps it": "Strictă: generatorul o respectă mereu", + "Soft: the generator keeps it where it can": "Flexibilă: generatorul o respectă unde poate", + "Weight (1 to 3)": "Pondere (de la 1 la 3)", + "Most lessons a day": "Cel mult ore pe zi", + "Period": "Oră", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Profesor (nume de utilizator)", + "Activity (group:subject)": "Activitate (grupă:materie)", + "Say who or what the wish is about.": "Indicați la cine sau la ce se referă dorința.", + "Choose at least one period.": "Alegeți cel puțin o oră.", + "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", + "Timetable": "Orar", + "Wishes": "Dorințe", + "Timetable wishes": "Dorințe pentru orar" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index a7e47901..47c184c1 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1007,7 +1007,23 @@ "Reason": "Motiv", "Why the run failed": "De ce a eșuat rularea", "Published at": "Publicat la", - "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă" + "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă", + "Edit wish": "Editează dorința", + "New wish": "Dorință nouă", + "Hard: the generator always keeps it": "Strictă: generatorul o respectă mereu", + "Soft: the generator keeps it where it can": "Flexibilă: generatorul o respectă unde poate", + "Weight (1 to 3)": "Pondere (de la 1 la 3)", + "Most lessons a day": "Cel mult ore pe zi", + "Period": "Oră", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Profesor (nume de utilizator)", + "Activity (group:subject)": "Activitate (grupă:materie)", + "Say who or what the wish is about.": "Indicați la cine sau la ce se referă dorința.", + "Choose at least one period.": "Alegeți cel puțin o oră.", + "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", + "Timetable": "Orar", + "Wishes": "Dorințe", + "Timetable wishes": "Dorințe pentru orar" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index b81511ea..7df38c78 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Почему запуск не удался", "Published at": "Опубликован", - "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки" + "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки", + "Edit wish": "Изменить пожелание", + "New wish": "Новое пожелание", + "Hard: the generator always keeps it": "Обязательное: генератор всегда его соблюдает", + "Soft: the generator keeps it where it can": "Гибкое: генератор соблюдает его, где может", + "Weight (1 to 3)": "Вес (от 1 до 3)", + "Most lessons a day": "Не больше уроков в день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (имя пользователя)", + "Activity (group:subject)": "Занятие (группа:предмет)", + "Say who or what the wish is about.": "Укажите, кого или что касается пожелание.", + "Choose at least one period.": "Выберите хотя бы один урок.", + "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", + "Timetable": "Расписание", + "Wishes": "Пожелания", + "Timetable wishes": "Пожелания к расписанию" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index eb715f66..8ee034a6 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Почему запуск не удался", "Published at": "Опубликован", - "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки" + "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки", + "Edit wish": "Изменить пожелание", + "New wish": "Новое пожелание", + "Hard: the generator always keeps it": "Обязательное: генератор всегда его соблюдает", + "Soft: the generator keeps it where it can": "Гибкое: генератор соблюдает его, где может", + "Weight (1 to 3)": "Вес (от 1 до 3)", + "Most lessons a day": "Не больше уроков в день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (имя пользователя)", + "Activity (group:subject)": "Занятие (группа:предмет)", + "Say who or what the wish is about.": "Укажите, кого или что касается пожелание.", + "Choose at least one period.": "Выберите хотя бы один урок.", + "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", + "Timetable": "Расписание", + "Wishes": "Пожелания", + "Timetable wishes": "Пожелания к расписанию" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index ccd9de81..9f055f7d 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Dôvod", "Why the run failed": "Prečo beh zlyhal", "Published at": "Zverejnené", - "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín" + "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín", + "Edit wish": "Upraviť želanie", + "New wish": "Nové želanie", + "Hard: the generator always keeps it": "Pevné: generátor ho vždy dodrží", + "Soft: the generator keeps it where it can": "Mäkké: generátor ho dodrží, kde sa dá", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Najviac hodín denne", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učiteľ (používateľské meno)", + "Activity (group:subject)": "Aktivita (skupina:predmet)", + "Say who or what the wish is about.": "Uveďte, koho alebo čoho sa želanie týka.", + "Choose at least one period.": "Vyberte aspoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", + "Timetable": "Rozvrh", + "Wishes": "Želania", + "Timetable wishes": "Želania k rozvrhu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index c1e92be7..f461d2a9 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1007,7 +1007,23 @@ "Reason": "Dôvod", "Why the run failed": "Prečo beh zlyhal", "Published at": "Zverejnené", - "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín" + "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín", + "Edit wish": "Upraviť želanie", + "New wish": "Nové želanie", + "Hard: the generator always keeps it": "Pevné: generátor ho vždy dodrží", + "Soft: the generator keeps it where it can": "Mäkké: generátor ho dodrží, kde sa dá", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Najviac hodín denne", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učiteľ (používateľské meno)", + "Activity (group:subject)": "Aktivita (skupina:predmet)", + "Say who or what the wish is about.": "Uveďte, koho alebo čoho sa želanie týka.", + "Choose at least one period.": "Vyberte aspoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", + "Timetable": "Rozvrh", + "Wishes": "Želania", + "Timetable wishes": "Želania k rozvrhu" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index 39b463b8..a454a528 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zakaj zagon ni uspel", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur" + "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur", + "Edit wish": "Uredi željo", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obvezna: generator jo vedno upošteva", + "Soft: the generator keeps it where it can": "Prožna: generator jo upošteva, kjer lahko", + "Weight (1 to 3)": "Utež (od 1 do 3)", + "Most lessons a day": "Največ ur na dan", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Učitelj (uporabniško ime)", + "Activity (group:subject)": "Dejavnost (skupina:predmet)", + "Say who or what the wish is about.": "Navedite, na koga ali kaj se želja nanaša.", + "Choose at least one period.": "Izberite vsaj eno uro.", + "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", + "Timetable": "Urnik", + "Wishes": "Želje", + "Timetable wishes": "Želje za urnik" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index ad8b8f79..437c3f13 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zakaj zagon ni uspel", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur" + "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur", + "Edit wish": "Uredi željo", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obvezna: generator jo vedno upošteva", + "Soft: the generator keeps it where it can": "Prožna: generator jo upošteva, kjer lahko", + "Weight (1 to 3)": "Utež (od 1 do 3)", + "Most lessons a day": "Največ ur na dan", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Učitelj (uporabniško ime)", + "Activity (group:subject)": "Dejavnost (skupina:predmet)", + "Say who or what the wish is about.": "Navedite, na koga ali kaj se želja nanaša.", + "Choose at least one period.": "Izberite vsaj eno uro.", + "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", + "Timetable": "Urnik", + "Wishes": "Želje", + "Timetable wishes": "Želje za urnik" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 3c43b018..90d32143 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Arsyeja", "Why the run failed": "Pse dështoi ekzekutimi", "Published at": "Publikuar më", - "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft" + "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft", + "Edit wish": "Ndrysho dëshirën", + "New wish": "Dëshirë e re", + "Hard: the generator always keeps it": "E detyrueshme: gjeneratori e respekton gjithmonë", + "Soft: the generator keeps it where it can": "E butë: gjeneratori e respekton kur mundet", + "Weight (1 to 3)": "Pesha (nga 1 deri në 3)", + "Most lessons a day": "Së shumti orë mësimi në ditë", + "Period": "Orë", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Mësues (emri i përdoruesit)", + "Activity (group:subject)": "Aktivitet (grup:lëndë)", + "Say who or what the wish is about.": "Tregoni për kë ose për çfarë është dëshira.", + "Choose at least one period.": "Zgjidhni të paktën një orë.", + "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", + "Timetable": "Orari", + "Wishes": "Dëshirat", + "Timetable wishes": "Dëshirat për orarin" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bf1d2e14..140814cb 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1007,7 +1007,23 @@ "Reason": "Arsyeja", "Why the run failed": "Pse dështoi ekzekutimi", "Published at": "Publikuar më", - "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft" + "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft", + "Edit wish": "Ndrysho dëshirën", + "New wish": "Dëshirë e re", + "Hard: the generator always keeps it": "E detyrueshme: gjeneratori e respekton gjithmonë", + "Soft: the generator keeps it where it can": "E butë: gjeneratori e respekton kur mundet", + "Weight (1 to 3)": "Pesha (nga 1 deri në 3)", + "Most lessons a day": "Së shumti orë mësimi në ditë", + "Period": "Orë", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Mësues (emri i përdoruesit)", + "Activity (group:subject)": "Aktivitet (grup:lëndë)", + "Say who or what the wish is about.": "Tregoni për kë ose për çfarë është dëshira.", + "Choose at least one period.": "Zgjidhni të paktën një orë.", + "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", + "Timetable": "Orari", + "Wishes": "Dëshirat", + "Timetable wishes": "Dëshirat për orarin" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index befb312d..d2a0cc59 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova", + "Edit wish": "Уреди жељу", + "New wish": "Нова жеља", + "Hard: the generator always keeps it": "Обавезна: генератор је увек поштује", + "Soft: the generator keeps it where it can": "Флексибилна: генератор је поштује где може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Највише часова дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведите на кога или шта се жеља односи.", + "Choose at least one period.": "Изаберите бар један час.", + "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", + "Timetable": "Распоред", + "Wishes": "Жеље", + "Timetable wishes": "Жеље за распоред" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 4724cf92..a66d5cbb 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova", + "Edit wish": "Уреди жељу", + "New wish": "Нова жеља", + "Hard: the generator always keeps it": "Обавезна: генератор је увек поштује", + "Soft: the generator keeps it where it can": "Флексибилна: генератор је поштује где може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Највише часова дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведите на кога или шта се жеља односи.", + "Choose at least one period.": "Изаберите бар један час.", + "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", + "Timetable": "Распоред", + "Wishes": "Жеље", + "Timetable wishes": "Жеље за распоред" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index e62e5a2a..4c4d112d 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Orsak", "Why the run failed": "Varför körningen misslyckades", "Published at": "Publicerat", - "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner" + "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner", + "Edit wish": "Redigera önskemål", + "New wish": "Nytt önskemål", + "Hard: the generator always keeps it": "Hårt: generatorn följer det alltid", + "Soft: the generator keeps it where it can": "Mjukt: generatorn följer det där den kan", + "Weight (1 to 3)": "Vikt (1 till 3)", + "Most lessons a day": "Högst antal lektioner per dag", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lärare (användarnamn)", + "Activity (group:subject)": "Aktivitet (grupp:ämne)", + "Say who or what the wish is about.": "Ange vem eller vad önskemålet gäller.", + "Choose at least one period.": "Välj minst en lektion.", + "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", + "Timetable": "Schema", + "Wishes": "Önskemål", + "Timetable wishes": "Schemaönskemål" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index adfdc18d..80ca608a 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1007,7 +1007,23 @@ "Reason": "Orsak", "Why the run failed": "Varför körningen misslyckades", "Published at": "Publicerat", - "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner" + "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner", + "Edit wish": "Redigera önskemål", + "New wish": "Nytt önskemål", + "Hard: the generator always keeps it": "Hårt: generatorn följer det alltid", + "Soft: the generator keeps it where it can": "Mjukt: generatorn följer det där den kan", + "Weight (1 to 3)": "Vikt (1 till 3)", + "Most lessons a day": "Högst antal lektioner per dag", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lärare (användarnamn)", + "Activity (group:subject)": "Aktivitet (grupp:ämne)", + "Say who or what the wish is about.": "Ange vem eller vad önskemålet gäller.", + "Choose at least one period.": "Välj minst en lektion.", + "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", + "Timetable": "Schema", + "Wishes": "Önskemål", + "Timetable wishes": "Schemaönskemål" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 471d8f14..552719b9 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Neden", "Why the run failed": "Çalıştırmanın neden başarısız olduğu", "Published at": "Yayımlanma zamanı", - "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı" + "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı", + "Edit wish": "Dileği düzenle", + "New wish": "Yeni dilek", + "Hard: the generator always keeps it": "Kesin: oluşturucu her zaman uyar", + "Soft: the generator keeps it where it can": "Esnek: oluşturucu elinden geldiğince uyar", + "Weight (1 to 3)": "Ağırlık (1 ile 3 arası)", + "Most lessons a day": "Günde en fazla ders", + "Period": "Ders saati", + "{day} period {number}": "{day} {number}. ders saati", + "Teacher (user name)": "Öğretmen (kullanıcı adı)", + "Activity (group:subject)": "Etkinlik (grup:ders)", + "Say who or what the wish is about.": "Dileğin kimi veya neyi kapsadığını belirtin.", + "Choose at least one period.": "En az bir ders saati seçin.", + "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", + "Timetable": "Ders programı", + "Wishes": "Dilekler", + "Timetable wishes": "Ders programı dilekleri" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index ebdca64a..2f55dbf2 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1007,7 +1007,23 @@ "Reason": "Neden", "Why the run failed": "Çalıştırmanın neden başarısız olduğu", "Published at": "Yayımlanma zamanı", - "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı" + "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı", + "Edit wish": "Dileği düzenle", + "New wish": "Yeni dilek", + "Hard: the generator always keeps it": "Kesin: oluşturucu her zaman uyar", + "Soft: the generator keeps it where it can": "Esnek: oluşturucu elinden geldiğince uyar", + "Weight (1 to 3)": "Ağırlık (1 ile 3 arası)", + "Most lessons a day": "Günde en fazla ders", + "Period": "Ders saati", + "{day} period {number}": "{day} {number}. ders saati", + "Teacher (user name)": "Öğretmen (kullanıcı adı)", + "Activity (group:subject)": "Etkinlik (grup:ders)", + "Say who or what the wish is about.": "Dileğin kimi veya neyi kapsadığını belirtin.", + "Choose at least one period.": "En az bir ders saati seçin.", + "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", + "Timetable": "Ders programı", + "Wishes": "Dilekler", + "Timetable wishes": "Ders programı dilekleri" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index 7f30c879..f930a322 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Чому запуск не вдався", "Published at": "Опубліковано", - "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки" + "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки", + "Edit wish": "Змінити побажання", + "New wish": "Нове побажання", + "Hard: the generator always keeps it": "Обов'язкове: генератор завжди його дотримується", + "Soft: the generator keeps it where it can": "Гнучке: генератор дотримується його, де може", + "Weight (1 to 3)": "Вага (від 1 до 3)", + "Most lessons a day": "Не більше уроків на день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (ім'я користувача)", + "Activity (group:subject)": "Заняття (група:предмет)", + "Say who or what the wish is about.": "Вкажіть, кого чи чого стосується побажання.", + "Choose at least one period.": "Виберіть хоча б один урок.", + "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", + "Timetable": "Розклад", + "Wishes": "Побажання", + "Timetable wishes": "Побажання до розкладу" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 91f24b07..713e5c03 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Чому запуск не вдався", "Published at": "Опубліковано", - "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки" + "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки", + "Edit wish": "Змінити побажання", + "New wish": "Нове побажання", + "Hard: the generator always keeps it": "Обов'язкове: генератор завжди його дотримується", + "Soft: the generator keeps it where it can": "Гнучке: генератор дотримується його, де може", + "Weight (1 to 3)": "Вага (від 1 до 3)", + "Most lessons a day": "Не більше уроків на день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (ім'я користувача)", + "Activity (group:subject)": "Заняття (група:предмет)", + "Say who or what the wish is about.": "Вкажіть, кого чи чого стосується побажання.", + "Choose at least one period.": "Виберіть хоча б один урок.", + "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", + "Timetable": "Розклад", + "Wishes": "Побажання", + "Timetable wishes": "Побажання до розкладу" }, "plurals": {} } diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 924ea0d1..2b566b9e 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -16,8 +16,8 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 3. Wishes -- [ ] 3.1 Wish editor page (Timetable, Wishes): list by teacher, group, room or activity; add, edit, delete; hard or soft with weight; period picker on the week grid. Verify: vitest on `src/utils/timetableWishes.js` (period key round trip, weight only for soft); Playwright "a timetabler marks a wish as hard". -- [ ] 3.2 Row `tt-hard-soft-wishes` is not set built here: it is built when section 5 respects the wishes. +- [x] 3.1 Wish editor page (Timetable, Wishes): list by teacher, group, room or activity; add, edit, delete; hard or soft with weight; period picker on the week grid. Verify: vitest on `src/utils/timetableWishes.js` (period key round trip, weight only for soft); Playwright "a timetabler marks a wish as hard". Built (section 3 PR): a declarative index page `TimetableWishes` (`/timetable/wishes`, quick filters per teacher, group, room, activity) whose create and edit dialog is replaced through the `form-dialog` slot by `src/dialogs/TimetableWishDialog.vue`; no new custom page, so the gate-69 ratchet holds. +- [x] 3.2 Row `tt-hard-soft-wishes` is not set built here: it is built when section 5 respects the wishes. ## 4. Scorer diff --git a/src/dialogs/TimetableWishDialog.vue b/src/dialogs/TimetableWishDialog.vue new file mode 100644 index 00000000..eef77483 --- /dev/null +++ b/src/dialogs/TimetableWishDialog.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/src/icons.js b/src/icons.js index 10192bee..5a210993 100644 --- a/src/icons.js +++ b/src/icons.js @@ -19,6 +19,7 @@ import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.v import AlertOutline from 'vue-material-design-icons/AlertOutline.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import BriefcaseOutline from 'vue-material-design-icons/BriefcaseOutline.vue' +import CalendarClock from 'vue-material-design-icons/CalendarClock.vue' import ChartBar from 'vue-material-design-icons/ChartBar.vue' import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import ChartTimeline from 'vue-material-design-icons/ChartTimeline.vue' @@ -50,6 +51,7 @@ export default { AlertOutline, BookOpenVariantOutline, BriefcaseOutline, + CalendarClock, ChartBar, ChartBoxOutline, ChartTimeline, diff --git a/src/manifest.json b/src/manifest.json index 6441f747..10826b84 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -55,6 +55,9 @@ { "id": "PortfolioIndexMenu", "label": "Portfolios", "icon": "FolderMultipleOutline", "route": "PortfolioIndex" } ] }, { "id": "Timesheet", "label": "Timesheet", "icon": "ClockOutline", "route": "Timesheet", "order": 50 }, + { "id": "TimetableMenu", "label": "Timetable", "icon": "CalendarClock", "route": "TimetableWishes", "order": 55, "permission": "admin", "children": [ + { "id": "TimetableWishesMenu", "label": "Wishes", "icon": "CalendarClock", "route": "TimetableWishes" } + ] }, { "id": "Documentation", "label": "Documentation", "icon": "BookOpenVariantOutline", "href": "https://planix.conduction.nl", "section": "footer", "order": 90 }, { "id": "StoreMenu", "label": "Store", "icon": "StoreOutline", "route": "Store", "section": "footer", "order": 92 }, { "id": "ReportsMenu", "label": "Reports", "icon": "ChartBoxOutline", "route": "Reports", "section": "footer", "order": 95 }, @@ -238,6 +241,19 @@ { "id": "ProjectLog", "_note": "The project log: issues, lessons, meetings and decisions with type filters, plus an Actions filter over the project's tasks with issueType action, and an Add action that creates such a task. The mix of two schemas under one filter row is what makes it custom.", "route": "/projects/:id/log", "type": "custom", "title": "Log", "component": "ProjectLog", "config": {} }, { "id": "TaskDetail", "_note": "Task detail that combines a CnObjectSidebar with an inline time-entry log written against the same task. A detail page renders the record; the embedded logging surface is what makes this custom.", "route": "/projects/:id/tasks/:taskId", "type": "custom", "title": "Task", "component": "TaskDetail", "config": {} }, { "id": "Timesheet", "_note": "Week timesheet: entries grouped per day with inline edit and a project selector. A grouped, editable grid over time entries, which no v2 list primitive expresses.", "route": "/timesheet", "type": "custom", "title": "Timesheet", "component": "Timesheet", "config": {} }, + { "id": "TimetableWishes", "_note": "The wishes the timetable generator weighs, listed by teacher, group, room or activity. Admins add, edit and delete them, which OpenRegister enforces on the timetableWish schema, so the page is a declarative index; only its create and edit dialog is replaced (form-dialog slot) so the periods are picked on the school week grid.", "route": "/timetable/wishes", "type": "index", "title": "Timetable wishes", "config": { + "register": "planninq", + "schema": "timetableWish", + "columns": ["appliesTo", "reference", "kind", "strength", "weight", "note"], + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Teacher", "filter": { "appliesTo": "teacher" } }, + { "label": "Group", "filter": { "appliesTo": "group" } }, + { "label": "Room", "filter": { "appliesTo": "room" } }, + { "label": "Activity", "filter": { "appliesTo": "activity" } } + ], + "defaultSort": [{ "field": "reference", "order": "asc" }] + }, "slots": { "form-dialog": "TimetableWishDialog" } }, { "id": "Boards", "_note": "Board picker: the projects a user can open a board for, as entry points rather than records. The cards route to /projects/:id and expose no record actions.", "route": "/boards", "type": "custom", "title": "Boards", "component": "Boards", "config": {} }, { "id": "PortfolioStatus", "_note": "One portfolio's projects with their lifecycle status, progress, dates, budget and six aspect statuses, under a roll-up per aspect with the worst state and a No report count. It combines the projects, their tasks and an admin setting into one table and a roll-up, which no index or dashboard widget expresses.", "route": "/portfolio/status", "type": "custom", "title": "Portfolio status", "component": "PortfolioStatus", "config": {} }, { "id": "PortfolioFinance", "_note": "Money across one portfolio: a row per project with budget, commitments, actual cost including labour from booked time, forecast and what remains, and the totals, for the projects whose money the viewer may see. It combines finance lines, time entries and projects into computed rows and a cross-checked total, which no index or dashboard widget expresses.", "route": "/portfolio/finance", "type": "custom", "title": "Portfolio finance", "component": "PortfolioFinance", "config": {} }, diff --git a/src/registry.js b/src/registry.js index d33fefe8..eb447a0b 100644 --- a/src/registry.js +++ b/src/registry.js @@ -16,6 +16,7 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. */ +import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' // NOTE: the dashboard's DashboardPanels component is NOT registered here. A // dashboard widget TYPE resolves against the library's own widget catalog via // registerDashboardWidget() in main.js; this registry is for page components @@ -49,6 +50,16 @@ function page(component) { return { kind: 'page', component } } +/** + * A component a manifest page mounts in one of its slots (page.slots). + * + * @param {object} component The Vue component. + * @return {object} Kind-tagged registry entry. + */ +function slot(component) { + return { kind: 'slot', component } +} + export default { Boards: page(Boards), MyWork: page(MyWork), @@ -68,4 +79,5 @@ export default { ProjectTimeline: page(ProjectTimeline), TaskDetail: page(TaskDetail), Timesheet: page(Timesheet), + TimetableWishDialog: slot(TimetableWishDialog), } diff --git a/src/utils/timetableWishes.js b/src/utils/timetableWishes.js new file mode 100644 index 00000000..b6b55667 --- /dev/null +++ b/src/utils/timetableWishes.js @@ -0,0 +1,204 @@ +/** + * Helpers for the timetable wish editor (timetabling-generator, section 3). + * + * A period key names one period of the school week grid, as in `wed-5`: the + * day, a dash and the period number counted from 1. The grid itself is the + * admin setting `timetable_period_grid` (days and periods with start and end). + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +/** + * The days a grid may use, in week order (TimetableGridService::WEEK_DAYS). + */ +export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +/** + * The wish kinds that name periods, and the one that needs a limit. + */ +export const PERIOD_KINDS = ['unavailable', 'avoid'] +export const LIMIT_KIND = 'maxPerDay' + +/** + * Monday to Friday, eight periods of 50 minutes from 08:30 (TimetableGridService::DEFAULT_GRID). + */ +export const DEFAULT_GRID = { + days: ['mon', 'tue', 'wed', 'thu', 'fri'], + periods: [ + { start: '08:30', end: '09:20' }, + { start: '09:20', end: '10:10' }, + { start: '10:10', end: '11:00' }, + { start: '11:00', end: '11:50' }, + { start: '11:50', end: '12:40' }, + { start: '12:40', end: '13:30' }, + { start: '13:30', end: '14:20' }, + { start: '14:20', end: '15:10' }, + ], +} + +/** + * The key of one period. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string} day A week day, as in `wed`. + * @param {number} number The period number, from 1. + * @return {string} The period key, as in `wed-5`. + */ +export function periodKey(day, number) { + return `${day}-${number}` +} + +/** + * The day and number of a period key, or null for a key that is not one. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string} key A period key. + * @return {{day: string, number: number}|null} The parts. + */ +export function parsePeriodKey(key) { + const match = /^(mon|tue|wed|thu|fri|sat|sun)-([1-9][0-9]?)$/.exec(String(key ?? '')) + if (match === null) { + return null + } + return { day: match[1], number: Number(match[2]) } +} + +/** + * The stored grid setting as an object; the default grid when it is missing or unreadable. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string|object|null} raw The `timetable_period_grid` setting. + * @return {{days: string[], periods: Array<{start: string, end: string}>}} The grid. + */ +export function readGrid(raw) { + let grid = raw + if (typeof raw === 'string') { + try { + grid = JSON.parse(raw) + } catch { + grid = null + } + } + const days = Array.isArray(grid?.days) ? grid.days.filter((day) => WEEK_DAYS.includes(day)) : [] + const periods = Array.isArray(grid?.periods) ? grid.periods : [] + if (days.length === 0 || periods.length === 0) { + return DEFAULT_GRID + } + return { days, periods } +} + +/** + * The rows of the period picker: one row per period number, one cell per day. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {{days: string[], periods: Array<{start: string, end: string}>}} grid The grid. + * @param {string[]} selected The chosen period keys. + * @return {Array<{number: number, start: string, end: string, cells: Array<{key: string, day: string, selected: boolean}>}>} The rows. + */ +export function gridRows(grid, selected = []) { + const chosen = new Set(selected) + return grid.periods.map((period, index) => ({ + number: index + 1, + start: period.start, + end: period.end, + cells: grid.days.map((day) => { + const key = periodKey(day, index + 1) + return { key, day, selected: chosen.has(key) } + }), + })) +} + +/** + * The chosen periods with one key switched on or off, in week order. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string[]} selected The chosen period keys. + * @param {string} key The key to switch. + * @return {string[]} The new selection. + */ +export function togglePeriod(selected, key) { + const next = selected.includes(key) ? selected.filter((item) => item !== key) : [...selected, key] + return sortPeriodKeys(next) +} + +/** + * Period keys in week order, then period order; keys that are not period keys are dropped. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string[]} keys The keys. + * @return {string[]} The sorted keys. + */ +export function sortPeriodKeys(keys) { + return keys + .map((key) => ({ key, parts: parsePeriodKey(key) })) + .filter((item) => item.parts !== null) + .sort((a, b) => (WEEK_DAYS.indexOf(a.parts.day) - WEEK_DAYS.indexOf(b.parts.day)) || (a.parts.number - b.parts.number)) + .map((item) => item.key) +} + +/** + * The form a wish is edited in, from a stored wish or empty for a new one. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object|null} wish A stored wish. + * @return {object} The form values. + */ +export function wishForm(wish) { + return { + appliesTo: wish?.appliesTo ?? 'teacher', + reference: wish?.reference ?? '', + kind: wish?.kind ?? 'unavailable', + periods: sortPeriodKeys(Array.isArray(wish?.periods) ? wish.periods : []), + limit: wish?.limit ?? null, + strength: wish?.strength ?? 'soft', + weight: wish?.weight ?? 1, + note: wish?.note ?? '', + } +} + +/** + * What is wrong with the form, as the field and a message key; empty when it can be saved. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object} form The form values. + * @return {Array<{field: string, message: string}>} The problems. + */ +export function wishProblems(form) { + const problems = [] + if (String(form.reference ?? '').trim() === '') { + problems.push({ field: 'reference', message: 'Say who or what the wish is about.' }) + } + if (PERIOD_KINDS.includes(form.kind) && form.periods.length === 0) { + problems.push({ field: 'periods', message: 'Choose at least one period.' }) + } + if (form.kind === LIMIT_KIND && !(Number.isInteger(Number(form.limit)) && Number(form.limit) >= 1)) { + problems.push({ field: 'limit', message: 'The limit is a whole number of 1 or more.' }) + } + return problems +} + +/** + * The object saved for a form: periods only for a kind that names periods, a limit only + * for "at most a number of lessons a day", and a weight only for a soft wish. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object} form The form values. + * @return {object} The wish to save. + */ +export function wishPayload(form) { + const payload = { + appliesTo: form.appliesTo, + reference: String(form.reference).trim(), + kind: form.kind, + periods: PERIOD_KINDS.includes(form.kind) ? sortPeriodKeys(form.periods) : [], + limit: form.kind === LIMIT_KIND ? Number(form.limit) : null, + strength: form.strength, + weight: form.strength === 'soft' ? Math.min(3, Math.max(1, Number(form.weight) || 1)) : null, + } + const note = String(form.note ?? '').trim() + if (note !== '') { + payload.note = note + } + return payload +} diff --git a/tests/e2e/timetable-wishes.spec.ts b/tests/e2e/timetable-wishes.spec.ts new file mode 100644 index 00000000..76847d11 --- /dev/null +++ b/tests/e2e/timetable-wishes.spec.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * E2E coverage for the timetable wish list (timetabling-generator, section 3). + * + * @e2e timetable-generator::a-timetabler-marks-a-wish-as-hard + * + * The hard wish is added through the dialog on the week grid; the soft wish is + * created through the API as the admin, so the list shows both. Both carry a + * run id and are removed at the end. + */ + +import type { APIRequestContext } from '@playwright/test' + +import { expect, request, test } from '@playwright/test' +import { BASE_URL } from './base-url.ts' +import { PLANNINQ_ROOT } from './nav.ts' + +const RUN = Date.now().toString(36) +const OBJECTS = '/index.php/apps/openregister/api/objects/planninq/timetableWish' +const TEACHER = `klaas-${RUN}` + +async function admin(): Promise { + return request.newContext({ + baseURL: BASE_URL, + httpCredentials: { + username: process.env.NC_ADMIN_USER ?? process.env.ADMIN_USER ?? 'admin', + password: process.env.NC_ADMIN_PASS ?? process.env.ADMIN_PASSWORD ?? 'admin', + send: 'always', + }, + extraHTTPHeaders: { 'Content-Type': 'application/json', 'OCS-APIRequest': 'true', Accept: 'application/json' }, + }) +} + +test.describe('Timetable wishes', () => { + let api: APIRequestContext + + test.beforeAll(async () => { + api = await admin() + }) + + test.afterAll(async () => { + const res = await api.get(`${OBJECTS}?reference=${TEACHER}`, { failOnStatusCode: false }) + const body = res.ok() ? await res.json() : { results: [] } + for (const wish of body.results ?? []) { + await api.delete(`${OBJECTS}/${wish.id ?? wish['@self']?.id}`, { failOnStatusCode: false }) + } + await api.dispose() + }) + + test('a timetabler marks a wish as hard', async ({ page }) => { + const soft = await api.post(OBJECTS, { + data: { appliesTo: 'teacher', reference: TEACHER, kind: 'avoid', periods: ['fri-8'], strength: 'soft', weight: 2 }, + }) + expect(soft.ok(), 'create the soft wish').toBe(true) + + await page.goto(`${PLANNINQ_ROOT}timetable/wishes`) + await page.getByRole('button', { name: /add|new/i }).first().click() + await page.getByTestId('wish-reference').locator('input').fill(TEACHER) + for (const period of [5, 6, 7, 8]) { + await page.getByTestId(`wish-period-wed-${period}`).locator('input').check() + } + await page.getByTestId('wish-strength-hard').locator('input').check() + await expect(page.getByTestId('wish-weight')).toHaveCount(0) + await page.getByTestId('wish-save').click() + + const rows = page.getByRole('row').filter({ hasText: TEACHER }) + await expect(rows).toHaveCount(2) + await expect(rows.filter({ hasText: /hard/i })).toHaveCount(1) + await expect(rows.filter({ hasText: /soft/i })).toContainText('2') + + const stored = await api.get(`${OBJECTS}?reference=${TEACHER}&strength=hard`) + const hard = (await stored.json()).results?.[0] + expect(hard).toMatchObject({ kind: 'unavailable', periods: ['wed-5', 'wed-6', 'wed-7', 'wed-8'], weight: null }) + }) +}) diff --git a/tests/vitest/timetableWishes.spec.js b/tests/vitest/timetableWishes.spec.js new file mode 100644 index 00000000..764c23d5 --- /dev/null +++ b/tests/vitest/timetableWishes.spec.js @@ -0,0 +1,124 @@ +/** + * Vitest tests for the timetable wish editor helpers (timetabling-generator, section 3). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + */ +import Ajv from 'ajv' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_GRID, + gridRows, + parsePeriodKey, + periodKey, + readGrid, + sortPeriodKeys, + togglePeriod, + wishForm, + wishPayload, + wishProblems, +} from '../../src/utils/timetableWishes.js' + +const register = JSON.parse(readFileSync(new URL('../../lib/Settings/planninq_register.json', import.meta.url), 'utf8')) +const fragment = register.components.schemas.timetableWish + +/** + * OpenRegister's nullable becomes a JSON Schema null type, so ajv reads the fragment as OR does. + * + * @param {object} property A property of the fragment. + * @return {object} The property ajv validates with. + */ +function asJsonSchema(property) { + const copy = { ...property } + delete copy.title + if (copy.nullable === true) { + delete copy.nullable + copy.type = [copy.type, 'null'] + } + return copy +} + +const validate = new Ajv({ strict: false }).compile({ + type: 'object', + required: fragment.required, + additionalProperties: false, + properties: Object.fromEntries(Object.entries(fragment.properties).map(([key, value]) => [key, asJsonSchema(value)])), +}) + +describe('period keys', () => { + it('round-trips a key through its parts', () => { + expect(periodKey('wed', 5)).toBe('wed-5') + expect(parsePeriodKey('wed-5')).toEqual({ day: 'wed', number: 5 }) + const parts = parsePeriodKey(periodKey('fri', 12)) + expect(periodKey(parts.day, parts.number)).toBe('fri-12') + }) + + it('refuses what the schema pattern refuses', () => { + expect(parsePeriodKey('wed-0')).toBeNull() + expect(parsePeriodKey('wednesday-1')).toBeNull() + expect(parsePeriodKey('')).toBeNull() + expect(parsePeriodKey(null)).toBeNull() + }) + + it('sorts by week day, then period, and drops what is not a key', () => { + expect(sortPeriodKeys(['fri-1', 'mon-10', 'mon-2', 'nope'])).toEqual(['mon-2', 'mon-10', 'fri-1']) + }) + + it('switches a period on and off', () => { + expect(togglePeriod(['tue-3'], 'mon-1')).toEqual(['mon-1', 'tue-3']) + expect(togglePeriod(['mon-1', 'tue-3'], 'mon-1')).toEqual(['tue-3']) + }) +}) + +describe('the week grid', () => { + it('reads the stored setting and falls back to the default grid', () => { + expect(readGrid('{"days":["mon","tue"],"periods":[{"start":"09:00","end":"10:00"}]}')) + .toEqual({ days: ['mon', 'tue'], periods: [{ start: '09:00', end: '10:00' }] }) + expect(readGrid('not json')).toBe(DEFAULT_GRID) + expect(readGrid(null)).toBe(DEFAULT_GRID) + expect(readGrid({ days: [], periods: [] })).toBe(DEFAULT_GRID) + }) + + it('lays out one row per period with a cell per day, marking the chosen ones', () => { + const rows = gridRows(readGrid({ days: ['mon', 'wed'], periods: [{ start: '08:30', end: '09:20' }, { start: '09:20', end: '10:10' }] }), ['wed-2']) + expect(rows).toHaveLength(2) + expect(rows[1]).toMatchObject({ number: 2, start: '09:20', end: '10:10' }) + expect(rows[1].cells.map((cell) => [cell.key, cell.selected])).toEqual([['mon-2', false], ['wed-2', true]]) + }) +}) + +describe('the wish that is saved', () => { + it('keeps a weight only for a soft wish (scenario: a timetabler marks a wish as hard)', () => { + const soft = wishPayload({ ...wishForm(null), reference: 'noor', periods: ['wed-5'], strength: 'soft', weight: 2 }) + expect(soft.weight).toBe(2) + const hard = wishPayload({ ...wishForm(soft), strength: 'hard' }) + expect(hard).toMatchObject({ strength: 'hard', weight: null, periods: ['wed-5'] }) + expect(validate(soft)).toBe(true) + expect(validate(hard)).toBe(true) + }) + + it('keeps periods only for a kind that names periods, and a limit only for at most a number a day', () => { + const limit = wishPayload({ ...wishForm(null), appliesTo: 'group', reference: '3A', kind: 'maxPerDay', periods: ['mon-1'], limit: '6' }) + expect(limit).toMatchObject({ periods: [], limit: 6 }) + expect(validate(limit)).toBe(true) + const avoid = wishPayload({ ...wishForm(null), reference: 'B12', appliesTo: 'room', kind: 'avoid', periods: ['tue-8', 'mon-8'], limit: 4, note: ' Cleaning ' }) + expect(avoid).toMatchObject({ periods: ['mon-8', 'tue-8'], limit: null, note: 'Cleaning' }) + expect(validate(avoid)).toBe(true) + }) + + it('clamps the weight to 1 to 3, as the schema allows', () => { + expect(wishPayload({ ...wishForm(null), reference: 'x', periods: ['mon-1'], weight: 9 }).weight).toBe(3) + expect(wishPayload({ ...wishForm(null), reference: 'x', periods: ['mon-1'], weight: 0 }).weight).toBe(1) + }) + + it('names what stops the form from saving', () => { + expect(wishProblems(wishForm(null)).map((problem) => problem.field)).toEqual(['reference', 'periods']) + expect(wishProblems({ ...wishForm(null), reference: 'noor', kind: 'maxPerDay', limit: 0 }).map((problem) => problem.field)).toEqual(['limit']) + expect(wishProblems({ ...wishForm(null), reference: 'noor', kind: 'noGaps' })).toEqual([]) + }) + + it('opens a stored wish as it was saved', () => { + const stored = { appliesTo: 'activity', reference: '3A:Maths', kind: 'sameRoom', periods: [], limit: null, strength: 'hard', weight: null } + expect(wishPayload(wishForm(stored))).toEqual(stored) + }) +}) From 3a1ccee12c5a63f2d0327ac7198fd8a4a730fbf8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 05:27:45 +0200 Subject: [PATCH 05/14] feat(timetable): generate a timetable scenario that keeps every hard wish, in time-boxed background steps --- appinfo/routes.php | 2 + l10n/be.js | 23 +- l10n/be.json | 23 +- l10n/bg.js | 23 +- l10n/bg.json | 23 +- l10n/bs.js | 23 +- l10n/bs.json | 23 +- l10n/ca.js | 23 +- l10n/ca.json | 23 +- l10n/cs.js | 23 +- l10n/cs.json | 23 +- l10n/da.js | 23 +- l10n/da.json | 23 +- l10n/de.js | 23 +- l10n/de.json | 23 +- l10n/el.js | 23 +- l10n/el.json | 23 +- l10n/en.js | 23 +- l10n/en.json | 23 +- l10n/es.js | 23 +- l10n/es.json | 23 +- l10n/et.js | 23 +- l10n/et.json | 23 +- l10n/fi.js | 23 +- l10n/fi.json | 23 +- l10n/fr.js | 23 +- l10n/fr.json | 23 +- l10n/ga.js | 23 +- l10n/ga.json | 23 +- l10n/hr.js | 23 +- l10n/hr.json | 23 +- l10n/hu.js | 23 +- l10n/hu.json | 23 +- l10n/is.js | 23 +- l10n/is.json | 23 +- l10n/it.js | 23 +- l10n/it.json | 23 +- l10n/lb.js | 23 +- l10n/lb.json | 23 +- l10n/lt.js | 23 +- l10n/lt.json | 23 +- l10n/lv.js | 23 +- l10n/lv.json | 23 +- l10n/mk.js | 23 +- l10n/mk.json | 23 +- l10n/mt.js | 23 +- l10n/mt.json | 23 +- l10n/nb.js | 23 +- l10n/nb.json | 23 +- l10n/nl.js | 23 +- l10n/nl.json | 23 +- l10n/pl.js | 23 +- l10n/pl.json | 23 +- l10n/pt.js | 23 +- l10n/pt.json | 23 +- l10n/rm.js | 23 +- l10n/rm.json | 23 +- l10n/ro.js | 23 +- l10n/ro.json | 23 +- l10n/ru.js | 23 +- l10n/ru.json | 23 +- l10n/sk.js | 23 +- l10n/sk.json | 23 +- l10n/sl.js | 23 +- l10n/sl.json | 23 +- l10n/sq.js | 23 +- l10n/sq.json | 23 +- l10n/sr.js | 23 +- l10n/sr.json | 23 +- l10n/sv.js | 23 +- l10n/sv.json | 23 +- l10n/tr.js | 23 +- l10n/tr.json | 23 +- l10n/uk.js | 23 +- l10n/uk.json | 23 +- lib/AppInfo/Application.php | 6 + .../GenerateTimetableScenario.php | 90 +++++ .../TimetableScenarioController.php | 91 +++++ lib/Service/TimetableGenerationService.php | 230 ++++++++++++ lib/Service/TimetableScenarioStore.php | 185 +++++++++ lib/Timetabling/LocalSearchSolver.php | 353 ++++++++++++++++++ lib/Timetabling/PlacementOptions.php | 148 ++++++++ lib/Timetabling/SearchState.php | 226 +++++++++++ lib/Timetabling/SeededRandom.php | 91 +++++ lib/Timetabling/SolverResult.php | 72 ++++ lib/Timetabling/TimetableSolver.php | 48 +++ .../changes/timetabling-generator/design.md | 11 +- .../changes/timetabling-generator/tasks.md | 6 +- openspec/parity/capabilities.json | 7 +- src/components/TimetableScenarioSections.vue | 321 ++++++++++++++++ src/manifest.json | 13 +- src/registry.js | 4 +- src/utils/timetableScenarios.js | 86 +++++ tests/e2e/timetable-generator.spec.ts | 92 +++++ .../TimetableScenarioControllerTest.php | 110 ++++++ .../TimetableGenerationServiceTest.php | 320 ++++++++++++++++ .../Timetabling/LocalSearchSolverTest.php | 295 +++++++++++++++ tests/vitest/timetableScenarios.spec.js | 61 +++ 98 files changed, 4486 insertions(+), 84 deletions(-) create mode 100644 lib/BackgroundJob/GenerateTimetableScenario.php create mode 100644 lib/Controller/TimetableScenarioController.php create mode 100644 lib/Service/TimetableGenerationService.php create mode 100644 lib/Service/TimetableScenarioStore.php create mode 100644 lib/Timetabling/LocalSearchSolver.php create mode 100644 lib/Timetabling/PlacementOptions.php create mode 100644 lib/Timetabling/SearchState.php create mode 100644 lib/Timetabling/SeededRandom.php create mode 100644 lib/Timetabling/SolverResult.php create mode 100644 lib/Timetabling/TimetableSolver.php create mode 100644 src/components/TimetableScenarioSections.vue create mode 100644 src/utils/timetableScenarios.js create mode 100644 tests/e2e/timetable-generator.spec.ts create mode 100644 tests/unit/Controller/TimetableScenarioControllerTest.php create mode 100644 tests/unit/Service/TimetableGenerationServiceTest.php create mode 100644 tests/unit/Timetabling/LocalSearchSolverTest.php create mode 100644 tests/vitest/timetableScenarios.spec.js diff --git a/appinfo/routes.php b/appinfo/routes.php index 7303e607..99bd88eb 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -55,4 +55,6 @@ ['name' => 'timetable#publish', 'url' => '/api/timetable/sessions/publish', 'verb' => 'POST'], // Admins upload the activities and rooms sheets for the timetable generator (timetabling-generator 2.2). ['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'], + // Admins start a generator run for a timetable scenario (timetabling-generator 5.2). + ['name' => 'timetableScenario#generate', 'url' => '/api/timetable/scenarios/{id}/generate', 'verb' => 'POST'], ]); diff --git a/l10n/be.js b/l10n/be.js index c7e6af4f..d5e5d888 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", "Timetable": "Расклад", "Wishes": "Пажаданні", - "Timetable wishes": "Пажаданні да раскладу" + "Timetable wishes": "Пажаданні да раскладу", + "Generator run": "Запуск генератара", + "Generator progress": "Ход генератара", + "The run failed: {reason}": "Запуск не ўдаўся: {reason}", + "{placed} of {lessons} lessons placed": "Размешчана {placed} з {lessons} урокаў", + "Generate again": "Згенераваць зноў", + "Generate": "Згенераваць", + "Every lesson has a place.": "Кожны ўрок мае месца.", + "Lesson": "Урок", + "No free period and room": "Няма вольнага ўрока і кабінета", + "Broken soft wishes": "Парушаныя гнуткія пажаданні", + "Every soft wish is kept.": "Кожнае гнуткае пажаданне выканана.", + "Wish": "Пажаданне", + "Lessons": "Урокі", + "Generating: {percent}% of the time budget used": "Генерацыя: выкарыстана {percent}% часу", + "Waiting to start": "Чакае запуску", + "Published as draft lessons": "Апублікавана як чарнавікі ўрокаў", + "Could not read the scenario.": "Не ўдалося прачытаць сцэнарый.", + "Could not start the run.": "Не ўдалося пачаць запуск.", + "Scenarios": "Сцэнарыі", + "Timetable scenarios": "Сцэнарыі раскладу", + "Scenario": "Сцэнарый" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 21316bd9..56c7e1f4 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", "Timetable": "Расклад", "Wishes": "Пажаданні", - "Timetable wishes": "Пажаданні да раскладу" + "Timetable wishes": "Пажаданні да раскладу", + "Generator run": "Запуск генератара", + "Generator progress": "Ход генератара", + "The run failed: {reason}": "Запуск не ўдаўся: {reason}", + "{placed} of {lessons} lessons placed": "Размешчана {placed} з {lessons} урокаў", + "Generate again": "Згенераваць зноў", + "Generate": "Згенераваць", + "Every lesson has a place.": "Кожны ўрок мае месца.", + "Lesson": "Урок", + "No free period and room": "Няма вольнага ўрока і кабінета", + "Broken soft wishes": "Парушаныя гнуткія пажаданні", + "Every soft wish is kept.": "Кожнае гнуткае пажаданне выканана.", + "Wish": "Пажаданне", + "Lessons": "Урокі", + "Generating: {percent}% of the time budget used": "Генерацыя: выкарыстана {percent}% часу", + "Waiting to start": "Чакае запуску", + "Published as draft lessons": "Апублікавана як чарнавікі ўрокаў", + "Could not read the scenario.": "Не ўдалося прачытаць сцэнарый.", + "Could not start the run.": "Не ўдалося пачаць запуск.", + "Scenarios": "Сцэнарыі", + "Timetable scenarios": "Сцэнарыі раскладу", + "Scenario": "Сцэнарый" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index c96ada3b..4ce5251f 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", "Timetable": "Разписание", "Wishes": "Желания", - "Timetable wishes": "Желания за разписанието" + "Timetable wishes": "Желания за разписанието", + "Generator run": "Изпълнение на генератора", + "Generator progress": "Напредък на генератора", + "The run failed: {reason}": "Изпълнението е неуспешно: {reason}", + "{placed} of {lessons} lessons placed": "Поставени {placed} от {lessons} часа", + "Generate again": "Генериране отново", + "Generate": "Генериране", + "Every lesson has a place.": "Всеки час има място.", + "Lesson": "Час", + "No free period and room": "Няма свободен час и стая", + "Broken soft wishes": "Нарушени гъвкави желания", + "Every soft wish is kept.": "Всяко гъвкаво желание е спазено.", + "Wish": "Желание", + "Lessons": "Часове", + "Generating: {percent}% of the time budget used": "Генериране: използвани {percent}% от времето", + "Waiting to start": "Изчаква старт", + "Published as draft lessons": "Публикувано като чернови часове", + "Could not read the scenario.": "Сценарият не можа да бъде прочетен.", + "Could not start the run.": "Изпълнението не можа да започне.", + "Scenarios": "Сценарии", + "Timetable scenarios": "Сценарии за разписание", + "Scenario": "Сценарий" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index da51ca2b..f9f62d9f 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", "Timetable": "Разписание", "Wishes": "Желания", - "Timetable wishes": "Желания за разписанието" + "Timetable wishes": "Желания за разписанието", + "Generator run": "Изпълнение на генератора", + "Generator progress": "Напредък на генератора", + "The run failed: {reason}": "Изпълнението е неуспешно: {reason}", + "{placed} of {lessons} lessons placed": "Поставени {placed} от {lessons} часа", + "Generate again": "Генериране отново", + "Generate": "Генериране", + "Every lesson has a place.": "Всеки час има място.", + "Lesson": "Час", + "No free period and room": "Няма свободен час и стая", + "Broken soft wishes": "Нарушени гъвкави желания", + "Every soft wish is kept.": "Всяко гъвкаво желание е спазено.", + "Wish": "Желание", + "Lessons": "Часове", + "Generating: {percent}% of the time budget used": "Генериране: използвани {percent}% от времето", + "Waiting to start": "Изчаква старт", + "Published as draft lessons": "Публикувано като чернови часове", + "Could not read the scenario.": "Сценарият не можа да бъде прочетен.", + "Could not start the run.": "Изпълнението не можа да започне.", + "Scenarios": "Сценарии", + "Timetable scenarios": "Сценарии за разписание", + "Scenario": "Сценарий" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index fd4bd0f5..381850eb 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", "Timetable": "Raspored", "Wishes": "Želje", - "Timetable wishes": "Želje za raspored" + "Timetable wishes": "Želje za raspored", + "Generator run": "Pokretanje generatora", + "Generator progress": "Napredak generatora", + "The run failed: {reason}": "Pokretanje nije uspjelo: {reason}", + "{placed} of {lessons} lessons placed": "Smješteno {placed} od {lessons} časova", + "Generate again": "Generiši ponovo", + "Generate": "Generiši", + "Every lesson has a place.": "Svaki čas ima mjesto.", + "Lesson": "Čas", + "No free period and room": "Nema slobodnog časa i učionice", + "Broken soft wishes": "Prekršene fleksibilne želje", + "Every soft wish is kept.": "Svaka fleksibilna želja je poštovana.", + "Wish": "Želja", + "Lessons": "Časovi", + "Generating: {percent}% of the time budget used": "Generisanje: iskorišteno {percent}% vremena", + "Waiting to start": "Čeka početak", + "Published as draft lessons": "Objavljeno kao nacrti časova", + "Could not read the scenario.": "Scenarij nije moguće pročitati.", + "Could not start the run.": "Pokretanje nije moguće započeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji rasporeda", + "Scenario": "Scenarij" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 5e0c4b7d..704e09e8 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", "Timetable": "Raspored", "Wishes": "Želje", - "Timetable wishes": "Želje za raspored" + "Timetable wishes": "Želje za raspored", + "Generator run": "Pokretanje generatora", + "Generator progress": "Napredak generatora", + "The run failed: {reason}": "Pokretanje nije uspjelo: {reason}", + "{placed} of {lessons} lessons placed": "Smješteno {placed} od {lessons} časova", + "Generate again": "Generiši ponovo", + "Generate": "Generiši", + "Every lesson has a place.": "Svaki čas ima mjesto.", + "Lesson": "Čas", + "No free period and room": "Nema slobodnog časa i učionice", + "Broken soft wishes": "Prekršene fleksibilne želje", + "Every soft wish is kept.": "Svaka fleksibilna želja je poštovana.", + "Wish": "Želja", + "Lessons": "Časovi", + "Generating: {percent}% of the time budget used": "Generisanje: iskorišteno {percent}% vremena", + "Waiting to start": "Čeka početak", + "Published as draft lessons": "Objavljeno kao nacrti časova", + "Could not read the scenario.": "Scenarij nije moguće pročitati.", + "Could not start the run.": "Pokretanje nije moguće započeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji rasporeda", + "Scenario": "Scenarij" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index ca9d46e6..2f166e6c 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", "Timetable": "Horari", "Wishes": "Desitjos", - "Timetable wishes": "Desitjos d'horari" + "Timetable wishes": "Desitjos d'horari", + "Generator run": "Execució del generador", + "Generator progress": "Progrés del generador", + "The run failed: {reason}": "L'execució ha fallat: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} classes col·locades", + "Generate again": "Genera de nou", + "Generate": "Genera", + "Every lesson has a place.": "Cada classe té un lloc.", + "Lesson": "Classe", + "No free period and room": "Cap franja ni aula lliure", + "Broken soft wishes": "Desitjos flexibles incomplerts", + "Every soft wish is kept.": "Es respecta cada desig flexible.", + "Wish": "Desig", + "Lessons": "Classes", + "Generating: {percent}% of the time budget used": "S'està generant: {percent}% del temps usat", + "Waiting to start": "Esperant per començar", + "Published as draft lessons": "Publicat com a classes en esborrany", + "Could not read the scenario.": "No s'ha pogut llegir l'escenari.", + "Could not start the run.": "No s'ha pogut iniciar l'execució.", + "Scenarios": "Escenaris", + "Timetable scenarios": "Escenaris d'horari", + "Scenario": "Escenari" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index ca6f1406..b191ed4f 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", "Timetable": "Horari", "Wishes": "Desitjos", - "Timetable wishes": "Desitjos d'horari" + "Timetable wishes": "Desitjos d'horari", + "Generator run": "Execució del generador", + "Generator progress": "Progrés del generador", + "The run failed: {reason}": "L'execució ha fallat: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} classes col·locades", + "Generate again": "Genera de nou", + "Generate": "Genera", + "Every lesson has a place.": "Cada classe té un lloc.", + "Lesson": "Classe", + "No free period and room": "Cap franja ni aula lliure", + "Broken soft wishes": "Desitjos flexibles incomplerts", + "Every soft wish is kept.": "Es respecta cada desig flexible.", + "Wish": "Desig", + "Lessons": "Classes", + "Generating: {percent}% of the time budget used": "S'està generant: {percent}% del temps usat", + "Waiting to start": "Esperant per començar", + "Published as draft lessons": "Publicat com a classes en esborrany", + "Could not read the scenario.": "No s'ha pogut llegir l'escenari.", + "Could not start the run.": "No s'ha pogut iniciar l'execució.", + "Scenarios": "Escenaris", + "Timetable scenarios": "Escenaris d'horari", + "Scenario": "Escenari" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index e270e46e..1320cd86 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", "Timetable": "Rozvrh", "Wishes": "Přání", - "Timetable wishes": "Přání k rozvrhu" + "Timetable wishes": "Přání k rozvrhu", + "Generator run": "Běh generátoru", + "Generator progress": "Průběh generátoru", + "The run failed: {reason}": "Běh selhal: {reason}", + "{placed} of {lessons} lessons placed": "Umístěno {placed} z {lessons} hodin", + "Generate again": "Generovat znovu", + "Generate": "Generovat", + "Every lesson has a place.": "Každá hodina má místo.", + "Lesson": "Hodina", + "No free period and room": "Žádná volná hodina a učebna", + "Broken soft wishes": "Porušená měkká přání", + "Every soft wish is kept.": "Každé měkké přání je dodrženo.", + "Wish": "Přání", + "Lessons": "Hodiny", + "Generating: {percent}% of the time budget used": "Generování: využito {percent} % času", + "Waiting to start": "Čeká na spuštění", + "Published as draft lessons": "Zveřejněno jako koncepty hodin", + "Could not read the scenario.": "Scénář se nepodařilo načíst.", + "Could not start the run.": "Běh se nepodařilo spustit.", + "Scenarios": "Scénáře", + "Timetable scenarios": "Scénáře rozvrhu", + "Scenario": "Scénář" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 39c076c6..f312dcd5 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", "Timetable": "Rozvrh", "Wishes": "Přání", - "Timetable wishes": "Přání k rozvrhu" + "Timetable wishes": "Přání k rozvrhu", + "Generator run": "Běh generátoru", + "Generator progress": "Průběh generátoru", + "The run failed: {reason}": "Běh selhal: {reason}", + "{placed} of {lessons} lessons placed": "Umístěno {placed} z {lessons} hodin", + "Generate again": "Generovat znovu", + "Generate": "Generovat", + "Every lesson has a place.": "Každá hodina má místo.", + "Lesson": "Hodina", + "No free period and room": "Žádná volná hodina a učebna", + "Broken soft wishes": "Porušená měkká přání", + "Every soft wish is kept.": "Každé měkké přání je dodrženo.", + "Wish": "Přání", + "Lessons": "Hodiny", + "Generating: {percent}% of the time budget used": "Generování: využito {percent} % času", + "Waiting to start": "Čeká na spuštění", + "Published as draft lessons": "Zveřejněno jako koncepty hodin", + "Could not read the scenario.": "Scénář se nepodařilo načíst.", + "Could not start the run.": "Běh se nepodařilo spustit.", + "Scenarios": "Scénáře", + "Timetable scenarios": "Scénáře rozvrhu", + "Scenario": "Scénář" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 2749c71f..a039c2c5 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", "Timetable": "Skema", "Wishes": "Ønsker", - "Timetable wishes": "Skemaønsker" + "Timetable wishes": "Skemaønsker", + "Generator run": "Generatorkørsel", + "Generator progress": "Generatorens fremskridt", + "The run failed: {reason}": "Kørslen mislykkedes: {reason}", + "{placed} of {lessons} lessons placed": "{placed} af {lessons} lektioner placeret", + "Generate again": "Generér igen", + "Generate": "Generér", + "Every lesson has a place.": "Hver lektion har en plads.", + "Lesson": "Lektion", + "No free period and room": "Ingen ledig lektion og lokale", + "Broken soft wishes": "Brudte bløde ønsker", + "Every soft wish is kept.": "Hvert blødt ønske er overholdt.", + "Wish": "Ønske", + "Lessons": "Lektioner", + "Generating: {percent}% of the time budget used": "Genererer: {percent}% af tiden brugt", + "Waiting to start": "Venter på start", + "Published as draft lessons": "Udgivet som kladdelektioner", + "Could not read the scenario.": "Scenariet kunne ikke læses.", + "Could not start the run.": "Kørslen kunne ikke startes.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Skemascenarier", + "Scenario": "Scenarie" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 3b49229a..0c9298c1 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", "Timetable": "Skema", "Wishes": "Ønsker", - "Timetable wishes": "Skemaønsker" + "Timetable wishes": "Skemaønsker", + "Generator run": "Generatorkørsel", + "Generator progress": "Generatorens fremskridt", + "The run failed: {reason}": "Kørslen mislykkedes: {reason}", + "{placed} of {lessons} lessons placed": "{placed} af {lessons} lektioner placeret", + "Generate again": "Generér igen", + "Generate": "Generér", + "Every lesson has a place.": "Hver lektion har en plads.", + "Lesson": "Lektion", + "No free period and room": "Ingen ledig lektion og lokale", + "Broken soft wishes": "Brudte bløde ønsker", + "Every soft wish is kept.": "Hvert blødt ønske er overholdt.", + "Wish": "Ønske", + "Lessons": "Lektioner", + "Generating: {percent}% of the time budget used": "Genererer: {percent}% af tiden brugt", + "Waiting to start": "Venter på start", + "Published as draft lessons": "Udgivet som kladdelektioner", + "Could not read the scenario.": "Scenariet kunne ikke læses.", + "Could not start the run.": "Kørslen kunne ikke startes.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Skemascenarier", + "Scenario": "Scenarie" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index 8d26d5a7..dd58905e 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", "Timetable": "Stundenplan", "Wishes": "Wünsche", - "Timetable wishes": "Stundenplanwünsche" + "Timetable wishes": "Stundenplanwünsche", + "Generator run": "Generatorlauf", + "Generator progress": "Fortschritt des Generators", + "The run failed: {reason}": "Der Lauf ist fehlgeschlagen: {reason}", + "{placed} of {lessons} lessons placed": "{placed} von {lessons} Stunden platziert", + "Generate again": "Erneut generieren", + "Generate": "Generieren", + "Every lesson has a place.": "Jede Stunde hat einen Platz.", + "Lesson": "Stunde", + "No free period and room": "Keine freie Stunde und kein freier Raum", + "Broken soft wishes": "Verletzte weiche Wünsche", + "Every soft wish is kept.": "Jeder weiche Wunsch ist eingehalten.", + "Wish": "Wunsch", + "Lessons": "Stunden", + "Generating: {percent}% of the time budget used": "Wird generiert: {percent}% der Zeit verbraucht", + "Waiting to start": "Wartet auf den Start", + "Published as draft lessons": "Als Entwurfsstunden veröffentlicht", + "Could not read the scenario.": "Das Szenario konnte nicht gelesen werden.", + "Could not start the run.": "Der Lauf konnte nicht gestartet werden.", + "Scenarios": "Szenarien", + "Timetable scenarios": "Stundenplanszenarien", + "Scenario": "Szenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 5fc84b4f..27544709 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", "Timetable": "Stundenplan", "Wishes": "Wünsche", - "Timetable wishes": "Stundenplanwünsche" + "Timetable wishes": "Stundenplanwünsche", + "Generator run": "Generatorlauf", + "Generator progress": "Fortschritt des Generators", + "The run failed: {reason}": "Der Lauf ist fehlgeschlagen: {reason}", + "{placed} of {lessons} lessons placed": "{placed} von {lessons} Stunden platziert", + "Generate again": "Erneut generieren", + "Generate": "Generieren", + "Every lesson has a place.": "Jede Stunde hat einen Platz.", + "Lesson": "Stunde", + "No free period and room": "Keine freie Stunde und kein freier Raum", + "Broken soft wishes": "Verletzte weiche Wünsche", + "Every soft wish is kept.": "Jeder weiche Wunsch ist eingehalten.", + "Wish": "Wunsch", + "Lessons": "Stunden", + "Generating: {percent}% of the time budget used": "Wird generiert: {percent}% der Zeit verbraucht", + "Waiting to start": "Wartet auf den Start", + "Published as draft lessons": "Als Entwurfsstunden veröffentlicht", + "Could not read the scenario.": "Das Szenario konnte nicht gelesen werden.", + "Could not start the run.": "Der Lauf konnte nicht gestartet werden.", + "Scenarios": "Szenarien", + "Timetable scenarios": "Stundenplanszenarien", + "Scenario": "Szenario" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index f963e68d..2305ed50 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", "Timetable": "Ωρολόγιο πρόγραμμα", "Wishes": "Επιθυμίες", - "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος", + "Generator run": "Εκτέλεση γεννήτριας", + "Generator progress": "Πρόοδος γεννήτριας", + "The run failed: {reason}": "Η εκτέλεση απέτυχε: {reason}", + "{placed} of {lessons} lessons placed": "Τοποθετήθηκαν {placed} από {lessons} μαθήματα", + "Generate again": "Δημιουργία ξανά", + "Generate": "Δημιουργία", + "Every lesson has a place.": "Κάθε μάθημα έχει θέση.", + "Lesson": "Μάθημα", + "No free period and room": "Καμία ελεύθερη ώρα και αίθουσα", + "Broken soft wishes": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν", + "Every soft wish is kept.": "Κάθε ευέλικτη επιθυμία τηρείται.", + "Wish": "Επιθυμία", + "Lessons": "Μαθήματα", + "Generating: {percent}% of the time budget used": "Δημιουργία: χρησιμοποιήθηκε {percent}% του χρόνου", + "Waiting to start": "Αναμονή έναρξης", + "Published as draft lessons": "Δημοσιεύτηκε ως πρόχειρα μαθήματα", + "Could not read the scenario.": "Δεν ήταν δυνατή η ανάγνωση του σεναρίου.", + "Could not start the run.": "Δεν ήταν δυνατή η έναρξη της εκτέλεσης.", + "Scenarios": "Σενάρια", + "Timetable scenarios": "Σενάρια ωρολογίου προγράμματος", + "Scenario": "Σενάριο" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 3c39475e..ccb40592 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", "Timetable": "Ωρολόγιο πρόγραμμα", "Wishes": "Επιθυμίες", - "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος", + "Generator run": "Εκτέλεση γεννήτριας", + "Generator progress": "Πρόοδος γεννήτριας", + "The run failed: {reason}": "Η εκτέλεση απέτυχε: {reason}", + "{placed} of {lessons} lessons placed": "Τοποθετήθηκαν {placed} από {lessons} μαθήματα", + "Generate again": "Δημιουργία ξανά", + "Generate": "Δημιουργία", + "Every lesson has a place.": "Κάθε μάθημα έχει θέση.", + "Lesson": "Μάθημα", + "No free period and room": "Καμία ελεύθερη ώρα και αίθουσα", + "Broken soft wishes": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν", + "Every soft wish is kept.": "Κάθε ευέλικτη επιθυμία τηρείται.", + "Wish": "Επιθυμία", + "Lessons": "Μαθήματα", + "Generating: {percent}% of the time budget used": "Δημιουργία: χρησιμοποιήθηκε {percent}% του χρόνου", + "Waiting to start": "Αναμονή έναρξης", + "Published as draft lessons": "Δημοσιεύτηκε ως πρόχειρα μαθήματα", + "Could not read the scenario.": "Δεν ήταν δυνατή η ανάγνωση του σεναρίου.", + "Could not start the run.": "Δεν ήταν δυνατή η έναρξη της εκτέλεσης.", + "Scenarios": "Σενάρια", + "Timetable scenarios": "Σενάρια ωρολογίου προγράμματος", + "Scenario": "Σενάριο" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index a1c7c783..3679e9d5 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1002,7 +1002,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", "Timetable": "Timetable", "Wishes": "Wishes", - "Timetable wishes": "Timetable wishes" + "Timetable wishes": "Timetable wishes", + "Generator run": "Generator run", + "Generator progress": "Generator progress", + "The run failed: {reason}": "The run failed: {reason}", + "{placed} of {lessons} lessons placed": "{placed} of {lessons} lessons placed", + "Generate again": "Generate again", + "Generate": "Generate", + "Every lesson has a place.": "Every lesson has a place.", + "Lesson": "Lesson", + "No free period and room": "No free period and room", + "Broken soft wishes": "Broken soft wishes", + "Every soft wish is kept.": "Every soft wish is kept.", + "Wish": "Wish", + "Lessons": "Lessons", + "Generating: {percent}% of the time budget used": "Generating: {percent}% of the time budget used", + "Waiting to start": "Waiting to start", + "Published as draft lessons": "Published as draft lessons", + "Could not read the scenario.": "Could not read the scenario.", + "Could not start the run.": "Could not start the run.", + "Scenarios": "Scenarios", + "Timetable scenarios": "Timetable scenarios", + "Scenario": "Scenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index f3a66683..c976c906 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1001,7 +1001,28 @@ "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", "Timetable": "Timetable", "Wishes": "Wishes", - "Timetable wishes": "Timetable wishes" + "Timetable wishes": "Timetable wishes", + "Generator run": "Generator run", + "Generator progress": "Generator progress", + "The run failed: {reason}": "The run failed: {reason}", + "{placed} of {lessons} lessons placed": "{placed} of {lessons} lessons placed", + "Generate again": "Generate again", + "Generate": "Generate", + "Every lesson has a place.": "Every lesson has a place.", + "Lesson": "Lesson", + "No free period and room": "No free period and room", + "Broken soft wishes": "Broken soft wishes", + "Every soft wish is kept.": "Every soft wish is kept.", + "Wish": "Wish", + "Lessons": "Lessons", + "Generating: {percent}% of the time budget used": "Generating: {percent}% of the time budget used", + "Waiting to start": "Waiting to start", + "Published as draft lessons": "Published as draft lessons", + "Could not read the scenario.": "Could not read the scenario.", + "Could not start the run.": "Could not start the run.", + "Scenarios": "Scenarios", + "Timetable scenarios": "Timetable scenarios", + "Scenario": "Scenario" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index d6a918db..6941b4cf 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", "Timetable": "Horario", "Wishes": "Deseos", - "Timetable wishes": "Deseos de horario" + "Timetable wishes": "Deseos de horario", + "Generator run": "Ejecución del generador", + "Generator progress": "Progreso del generador", + "The run failed: {reason}": "La ejecución falló: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} clases colocadas", + "Generate again": "Generar de nuevo", + "Generate": "Generar", + "Every lesson has a place.": "Cada clase tiene un lugar.", + "Lesson": "Clase", + "No free period and room": "Sin franja ni aula libre", + "Broken soft wishes": "Deseos flexibles incumplidos", + "Every soft wish is kept.": "Se respeta cada deseo flexible.", + "Wish": "Deseo", + "Lessons": "Clases", + "Generating: {percent}% of the time budget used": "Generando: {percent} % del tiempo usado", + "Waiting to start": "Esperando para empezar", + "Published as draft lessons": "Publicado como clases en borrador", + "Could not read the scenario.": "No se pudo leer el escenario.", + "Could not start the run.": "No se pudo iniciar la ejecución.", + "Scenarios": "Escenarios", + "Timetable scenarios": "Escenarios de horario", + "Scenario": "Escenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index 8e0a49e6..ca56596b 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", "Timetable": "Horario", "Wishes": "Deseos", - "Timetable wishes": "Deseos de horario" + "Timetable wishes": "Deseos de horario", + "Generator run": "Ejecución del generador", + "Generator progress": "Progreso del generador", + "The run failed: {reason}": "La ejecución falló: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} clases colocadas", + "Generate again": "Generar de nuevo", + "Generate": "Generar", + "Every lesson has a place.": "Cada clase tiene un lugar.", + "Lesson": "Clase", + "No free period and room": "Sin franja ni aula libre", + "Broken soft wishes": "Deseos flexibles incumplidos", + "Every soft wish is kept.": "Se respeta cada deseo flexible.", + "Wish": "Deseo", + "Lessons": "Clases", + "Generating: {percent}% of the time budget used": "Generando: {percent} % del tiempo usado", + "Waiting to start": "Esperando para empezar", + "Published as draft lessons": "Publicado como clases en borrador", + "Could not read the scenario.": "No se pudo leer el escenario.", + "Could not start the run.": "No se pudo iniciar la ejecución.", + "Scenarios": "Escenarios", + "Timetable scenarios": "Escenarios de horario", + "Scenario": "Escenario" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index 90b2e52d..166bce92 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", "Timetable": "Tunniplaan", "Wishes": "Soovid", - "Timetable wishes": "Tunniplaani soovid" + "Timetable wishes": "Tunniplaani soovid", + "Generator run": "Generaatori käivitus", + "Generator progress": "Generaatori edenemine", + "The run failed: {reason}": "Käivitus ebaõnnestus: {reason}", + "{placed} of {lessons} lessons placed": "Paigutatud {placed} tundi {lessons}-st", + "Generate again": "Genereeri uuesti", + "Generate": "Genereeri", + "Every lesson has a place.": "Igal tunnil on koht.", + "Lesson": "Tund", + "No free period and room": "Vaba tundi ja ruumi pole", + "Broken soft wishes": "Rikutud paindlikud soovid", + "Every soft wish is kept.": "Iga paindlik soov on täidetud.", + "Wish": "Soov", + "Lessons": "Tunnid", + "Generating: {percent}% of the time budget used": "Genereerimine: kasutatud {percent}% ajast", + "Waiting to start": "Ootab algust", + "Published as draft lessons": "Avaldatud mustandtundidena", + "Could not read the scenario.": "Stsenaariumi ei saanud lugeda.", + "Could not start the run.": "Käivitust ei saanud alustada.", + "Scenarios": "Stsenaariumid", + "Timetable scenarios": "Tunniplaani stsenaariumid", + "Scenario": "Stsenaarium" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index fe6c19ba..3024d80a 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", "Timetable": "Tunniplaan", "Wishes": "Soovid", - "Timetable wishes": "Tunniplaani soovid" + "Timetable wishes": "Tunniplaani soovid", + "Generator run": "Generaatori käivitus", + "Generator progress": "Generaatori edenemine", + "The run failed: {reason}": "Käivitus ebaõnnestus: {reason}", + "{placed} of {lessons} lessons placed": "Paigutatud {placed} tundi {lessons}-st", + "Generate again": "Genereeri uuesti", + "Generate": "Genereeri", + "Every lesson has a place.": "Igal tunnil on koht.", + "Lesson": "Tund", + "No free period and room": "Vaba tundi ja ruumi pole", + "Broken soft wishes": "Rikutud paindlikud soovid", + "Every soft wish is kept.": "Iga paindlik soov on täidetud.", + "Wish": "Soov", + "Lessons": "Tunnid", + "Generating: {percent}% of the time budget used": "Genereerimine: kasutatud {percent}% ajast", + "Waiting to start": "Ootab algust", + "Published as draft lessons": "Avaldatud mustandtundidena", + "Could not read the scenario.": "Stsenaariumi ei saanud lugeda.", + "Could not start the run.": "Käivitust ei saanud alustada.", + "Scenarios": "Stsenaariumid", + "Timetable scenarios": "Tunniplaani stsenaariumid", + "Scenario": "Stsenaarium" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index b43ba315..1cffd9d1 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", "Timetable": "Lukujärjestys", "Wishes": "Toiveet", - "Timetable wishes": "Lukujärjestystoiveet" + "Timetable wishes": "Lukujärjestystoiveet", + "Generator run": "Generaattorin ajo", + "Generator progress": "Generaattorin edistyminen", + "The run failed: {reason}": "Ajo epäonnistui: {reason}", + "{placed} of {lessons} lessons placed": "{placed}/{lessons} tuntia sijoitettu", + "Generate again": "Luo uudelleen", + "Generate": "Luo", + "Every lesson has a place.": "Jokaisella tunnilla on paikka.", + "Lesson": "Tunti", + "No free period and room": "Ei vapaata tuntia ja tilaa", + "Broken soft wishes": "Rikotut joustavat toiveet", + "Every soft wish is kept.": "Jokainen joustava toive on noudatettu.", + "Wish": "Toive", + "Lessons": "Tunnit", + "Generating: {percent}% of the time budget used": "Luodaan: {percent} % ajasta käytetty", + "Waiting to start": "Odottaa aloitusta", + "Published as draft lessons": "Julkaistu luonnostunteina", + "Could not read the scenario.": "Skenaariota ei voitu lukea.", + "Could not start the run.": "Ajoa ei voitu aloittaa.", + "Scenarios": "Skenaariot", + "Timetable scenarios": "Lukujärjestysskenaariot", + "Scenario": "Skenaario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 4bb5cc6d..213ff06d 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", "Timetable": "Lukujärjestys", "Wishes": "Toiveet", - "Timetable wishes": "Lukujärjestystoiveet" + "Timetable wishes": "Lukujärjestystoiveet", + "Generator run": "Generaattorin ajo", + "Generator progress": "Generaattorin edistyminen", + "The run failed: {reason}": "Ajo epäonnistui: {reason}", + "{placed} of {lessons} lessons placed": "{placed}/{lessons} tuntia sijoitettu", + "Generate again": "Luo uudelleen", + "Generate": "Luo", + "Every lesson has a place.": "Jokaisella tunnilla on paikka.", + "Lesson": "Tunti", + "No free period and room": "Ei vapaata tuntia ja tilaa", + "Broken soft wishes": "Rikotut joustavat toiveet", + "Every soft wish is kept.": "Jokainen joustava toive on noudatettu.", + "Wish": "Toive", + "Lessons": "Tunnit", + "Generating: {percent}% of the time budget used": "Luodaan: {percent} % ajasta käytetty", + "Waiting to start": "Odottaa aloitusta", + "Published as draft lessons": "Julkaistu luonnostunteina", + "Could not read the scenario.": "Skenaariota ei voitu lukea.", + "Could not start the run.": "Ajoa ei voitu aloittaa.", + "Scenarios": "Skenaariot", + "Timetable scenarios": "Lukujärjestysskenaariot", + "Scenario": "Skenaario" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 196a2085..8fe820bc 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", "Timetable": "Emploi du temps", "Wishes": "Souhaits", - "Timetable wishes": "Souhaits d'emploi du temps" + "Timetable wishes": "Souhaits d'emploi du temps", + "Generator run": "Exécution du générateur", + "Generator progress": "Progression du générateur", + "The run failed: {reason}": "L'exécution a échoué : {reason}", + "{placed} of {lessons} lessons placed": "{placed} cours placés sur {lessons}", + "Generate again": "Générer à nouveau", + "Generate": "Générer", + "Every lesson has a place.": "Chaque cours a une place.", + "Lesson": "Cours", + "No free period and room": "Aucun créneau ni salle libre", + "Broken soft wishes": "Souhaits souples non respectés", + "Every soft wish is kept.": "Chaque souhait souple est respecté.", + "Wish": "Souhait", + "Lessons": "Cours", + "Generating: {percent}% of the time budget used": "Génération : {percent} % du temps utilisé", + "Waiting to start": "En attente de démarrage", + "Published as draft lessons": "Publié en cours brouillons", + "Could not read the scenario.": "Impossible de lire le scénario.", + "Could not start the run.": "Impossible de lancer l'exécution.", + "Scenarios": "Scénarios", + "Timetable scenarios": "Scénarios d'emploi du temps", + "Scenario": "Scénario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 2b668abd..2ac95cc1 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", "Timetable": "Emploi du temps", "Wishes": "Souhaits", - "Timetable wishes": "Souhaits d'emploi du temps" + "Timetable wishes": "Souhaits d'emploi du temps", + "Generator run": "Exécution du générateur", + "Generator progress": "Progression du générateur", + "The run failed: {reason}": "L'exécution a échoué : {reason}", + "{placed} of {lessons} lessons placed": "{placed} cours placés sur {lessons}", + "Generate again": "Générer à nouveau", + "Generate": "Générer", + "Every lesson has a place.": "Chaque cours a une place.", + "Lesson": "Cours", + "No free period and room": "Aucun créneau ni salle libre", + "Broken soft wishes": "Souhaits souples non respectés", + "Every soft wish is kept.": "Chaque souhait souple est respecté.", + "Wish": "Souhait", + "Lessons": "Cours", + "Generating: {percent}% of the time budget used": "Génération : {percent} % du temps utilisé", + "Waiting to start": "En attente de démarrage", + "Published as draft lessons": "Publié en cours brouillons", + "Could not read the scenario.": "Impossible de lire le scénario.", + "Could not start the run.": "Impossible de lancer l'exécution.", + "Scenarios": "Scénarios", + "Timetable scenarios": "Scénarios d'emploi du temps", + "Scenario": "Scénario" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 684e90ee..09b45a11 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", "Timetable": "Clár ama", "Wishes": "Mianta", - "Timetable wishes": "Mianta an chláir ama" + "Timetable wishes": "Mianta an chláir ama", + "Generator run": "Rith an ghineadóra", + "Generator progress": "Dul chun cinn an ghineadóra", + "The run failed: {reason}": "Theip ar an rith: {reason}", + "{placed} of {lessons} lessons placed": "{placed} as {lessons} ceacht curtha", + "Generate again": "Gin arís", + "Generate": "Gin", + "Every lesson has a place.": "Tá áit ag gach ceacht.", + "Lesson": "Ceacht", + "No free period and room": "Níl tréimhse ná seomra saor", + "Broken soft wishes": "Mianta boga briste", + "Every soft wish is kept.": "Coinnítear gach mian bhog.", + "Wish": "Mian", + "Lessons": "Ceachtanna", + "Generating: {percent}% of the time budget used": "Ag giniúint: {percent}% den am úsáidte", + "Waiting to start": "Ag fanacht le tosú", + "Published as draft lessons": "Foilsithe mar dhréachtcheachtanna", + "Could not read the scenario.": "Níorbh fhéidir an cás a léamh.", + "Could not start the run.": "Níorbh fhéidir an rith a thosú.", + "Scenarios": "Cásanna", + "Timetable scenarios": "Cásanna an chláir ama", + "Scenario": "Cás" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index c647eb2b..b54ad526 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", "Timetable": "Clár ama", "Wishes": "Mianta", - "Timetable wishes": "Mianta an chláir ama" + "Timetable wishes": "Mianta an chláir ama", + "Generator run": "Rith an ghineadóra", + "Generator progress": "Dul chun cinn an ghineadóra", + "The run failed: {reason}": "Theip ar an rith: {reason}", + "{placed} of {lessons} lessons placed": "{placed} as {lessons} ceacht curtha", + "Generate again": "Gin arís", + "Generate": "Gin", + "Every lesson has a place.": "Tá áit ag gach ceacht.", + "Lesson": "Ceacht", + "No free period and room": "Níl tréimhse ná seomra saor", + "Broken soft wishes": "Mianta boga briste", + "Every soft wish is kept.": "Coinnítear gach mian bhog.", + "Wish": "Mian", + "Lessons": "Ceachtanna", + "Generating: {percent}% of the time budget used": "Ag giniúint: {percent}% den am úsáidte", + "Waiting to start": "Ag fanacht le tosú", + "Published as draft lessons": "Foilsithe mar dhréachtcheachtanna", + "Could not read the scenario.": "Níorbh fhéidir an cás a léamh.", + "Could not start the run.": "Níorbh fhéidir an rith a thosú.", + "Scenarios": "Cásanna", + "Timetable scenarios": "Cásanna an chláir ama", + "Scenario": "Cás" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 81d77966..a22d7d9b 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", "Timetable": "Raspored", "Wishes": "Želje", - "Timetable wishes": "Želje za raspored" + "Timetable wishes": "Želje za raspored", + "Generator run": "Pokretanje generatora", + "Generator progress": "Napredak generatora", + "The run failed: {reason}": "Pokretanje nije uspjelo: {reason}", + "{placed} of {lessons} lessons placed": "Smješteno {placed} od {lessons} sati", + "Generate again": "Generiraj ponovno", + "Generate": "Generiraj", + "Every lesson has a place.": "Svaki sat ima mjesto.", + "Lesson": "Sat", + "No free period and room": "Nema slobodnog sata i učionice", + "Broken soft wishes": "Prekršene fleksibilne želje", + "Every soft wish is kept.": "Svaka fleksibilna želja je poštovana.", + "Wish": "Želja", + "Lessons": "Sati", + "Generating: {percent}% of the time budget used": "Generiranje: iskorišteno {percent}% vremena", + "Waiting to start": "Čeka početak", + "Published as draft lessons": "Objavljeno kao nacrti sati", + "Could not read the scenario.": "Scenarij nije moguće pročitati.", + "Could not start the run.": "Pokretanje nije moguće započeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji rasporeda", + "Scenario": "Scenarij" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index c291b044..803d1680 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", "Timetable": "Raspored", "Wishes": "Želje", - "Timetable wishes": "Želje za raspored" + "Timetable wishes": "Želje za raspored", + "Generator run": "Pokretanje generatora", + "Generator progress": "Napredak generatora", + "The run failed: {reason}": "Pokretanje nije uspjelo: {reason}", + "{placed} of {lessons} lessons placed": "Smješteno {placed} od {lessons} sati", + "Generate again": "Generiraj ponovno", + "Generate": "Generiraj", + "Every lesson has a place.": "Svaki sat ima mjesto.", + "Lesson": "Sat", + "No free period and room": "Nema slobodnog sata i učionice", + "Broken soft wishes": "Prekršene fleksibilne želje", + "Every soft wish is kept.": "Svaka fleksibilna želja je poštovana.", + "Wish": "Želja", + "Lessons": "Sati", + "Generating: {percent}% of the time budget used": "Generiranje: iskorišteno {percent}% vremena", + "Waiting to start": "Čeka početak", + "Published as draft lessons": "Objavljeno kao nacrti sati", + "Could not read the scenario.": "Scenarij nije moguće pročitati.", + "Could not start the run.": "Pokretanje nije moguće započeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji rasporeda", + "Scenario": "Scenarij" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 120b3e27..34b24492 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", "Timetable": "Órarend", "Wishes": "Kívánságok", - "Timetable wishes": "Órarendi kívánságok" + "Timetable wishes": "Órarendi kívánságok", + "Generator run": "Generátorfuttatás", + "Generator progress": "A generátor haladása", + "The run failed: {reason}": "A futtatás sikertelen: {reason}", + "{placed} of {lessons} lessons placed": "{lessons} órából {placed} elhelyezve", + "Generate again": "Újragenerálás", + "Generate": "Generálás", + "Every lesson has a place.": "Minden órának van helye.", + "Lesson": "Óra", + "No free period and room": "Nincs szabad óra és terem", + "Broken soft wishes": "Megsértett rugalmas kívánságok", + "Every soft wish is kept.": "Minden rugalmas kívánság teljesül.", + "Wish": "Kívánság", + "Lessons": "Órák", + "Generating: {percent}% of the time budget used": "Generálás: az idő {percent}%-a elhasználva", + "Waiting to start": "Indításra vár", + "Published as draft lessons": "Óratervezetként közzétéve", + "Could not read the scenario.": "A forgatókönyv nem olvasható.", + "Could not start the run.": "A futtatás nem indítható.", + "Scenarios": "Forgatókönyvek", + "Timetable scenarios": "Órarendi forgatókönyvek", + "Scenario": "Forgatókönyv" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 2d1c09e6..4eb3612f 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", "Timetable": "Órarend", "Wishes": "Kívánságok", - "Timetable wishes": "Órarendi kívánságok" + "Timetable wishes": "Órarendi kívánságok", + "Generator run": "Generátorfuttatás", + "Generator progress": "A generátor haladása", + "The run failed: {reason}": "A futtatás sikertelen: {reason}", + "{placed} of {lessons} lessons placed": "{lessons} órából {placed} elhelyezve", + "Generate again": "Újragenerálás", + "Generate": "Generálás", + "Every lesson has a place.": "Minden órának van helye.", + "Lesson": "Óra", + "No free period and room": "Nincs szabad óra és terem", + "Broken soft wishes": "Megsértett rugalmas kívánságok", + "Every soft wish is kept.": "Minden rugalmas kívánság teljesül.", + "Wish": "Kívánság", + "Lessons": "Órák", + "Generating: {percent}% of the time budget used": "Generálás: az idő {percent}%-a elhasználva", + "Waiting to start": "Indításra vár", + "Published as draft lessons": "Óratervezetként közzétéve", + "Could not read the scenario.": "A forgatókönyv nem olvasható.", + "Could not start the run.": "A futtatás nem indítható.", + "Scenarios": "Forgatókönyvek", + "Timetable scenarios": "Órarendi forgatókönyvek", + "Scenario": "Forgatókönyv" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index d5dd82b2..45bb1253 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", "Timetable": "Stundaskrá", "Wishes": "Óskir", - "Timetable wishes": "Óskir um stundaskrá" + "Timetable wishes": "Óskir um stundaskrá", + "Generator run": "Keyrsla rafals", + "Generator progress": "Framvinda rafals", + "The run failed: {reason}": "Keyrslan mistókst: {reason}", + "{placed} of {lessons} lessons placed": "{placed} af {lessons} kennslustundum raðað", + "Generate again": "Búa til aftur", + "Generate": "Búa til", + "Every lesson has a place.": "Hver kennslustund hefur stað.", + "Lesson": "Kennslustund", + "No free period and room": "Engin laus stund og stofa", + "Broken soft wishes": "Brotnar sveigjanlegar óskir", + "Every soft wish is kept.": "Hver sveigjanleg ósk er virt.", + "Wish": "Ósk", + "Lessons": "Kennslustundir", + "Generating: {percent}% of the time budget used": "Býr til: {percent}% af tímanum notað", + "Waiting to start": "Bíður eftir ræsingu", + "Published as draft lessons": "Birt sem drög að kennslustundum", + "Could not read the scenario.": "Ekki tókst að lesa sviðsmyndina.", + "Could not start the run.": "Ekki tókst að ræsa keyrsluna.", + "Scenarios": "Sviðsmyndir", + "Timetable scenarios": "Sviðsmyndir stundaskrár", + "Scenario": "Sviðsmynd" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 695953ce..156f3c8d 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", "Timetable": "Stundaskrá", "Wishes": "Óskir", - "Timetable wishes": "Óskir um stundaskrá" + "Timetable wishes": "Óskir um stundaskrá", + "Generator run": "Keyrsla rafals", + "Generator progress": "Framvinda rafals", + "The run failed: {reason}": "Keyrslan mistókst: {reason}", + "{placed} of {lessons} lessons placed": "{placed} af {lessons} kennslustundum raðað", + "Generate again": "Búa til aftur", + "Generate": "Búa til", + "Every lesson has a place.": "Hver kennslustund hefur stað.", + "Lesson": "Kennslustund", + "No free period and room": "Engin laus stund og stofa", + "Broken soft wishes": "Brotnar sveigjanlegar óskir", + "Every soft wish is kept.": "Hver sveigjanleg ósk er virt.", + "Wish": "Ósk", + "Lessons": "Kennslustundir", + "Generating: {percent}% of the time budget used": "Býr til: {percent}% af tímanum notað", + "Waiting to start": "Bíður eftir ræsingu", + "Published as draft lessons": "Birt sem drög að kennslustundum", + "Could not read the scenario.": "Ekki tókst að lesa sviðsmyndina.", + "Could not start the run.": "Ekki tókst að ræsa keyrsluna.", + "Scenarios": "Sviðsmyndir", + "Timetable scenarios": "Sviðsmyndir stundaskrár", + "Scenario": "Sviðsmynd" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index 84f734c1..ea59d56c 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", "Timetable": "Orario", "Wishes": "Desideri", - "Timetable wishes": "Desideri per l'orario" + "Timetable wishes": "Desideri per l'orario", + "Generator run": "Esecuzione del generatore", + "Generator progress": "Avanzamento del generatore", + "The run failed: {reason}": "L'esecuzione non è riuscita: {reason}", + "{placed} of {lessons} lessons placed": "{placed} lezioni su {lessons} collocate", + "Generate again": "Genera di nuovo", + "Generate": "Genera", + "Every lesson has a place.": "Ogni lezione ha un posto.", + "Lesson": "Lezione", + "No free period and room": "Nessuna ora né aula libera", + "Broken soft wishes": "Desideri flessibili non rispettati", + "Every soft wish is kept.": "Ogni desiderio flessibile è rispettato.", + "Wish": "Desiderio", + "Lessons": "Lezioni", + "Generating: {percent}% of the time budget used": "Generazione: {percent}% del tempo usato", + "Waiting to start": "In attesa di avvio", + "Published as draft lessons": "Pubblicato come lezioni in bozza", + "Could not read the scenario.": "Impossibile leggere lo scenario.", + "Could not start the run.": "Impossibile avviare l'esecuzione.", + "Scenarios": "Scenari", + "Timetable scenarios": "Scenari di orario", + "Scenario": "Scenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 8a398fd3..440420d3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", "Timetable": "Orario", "Wishes": "Desideri", - "Timetable wishes": "Desideri per l'orario" + "Timetable wishes": "Desideri per l'orario", + "Generator run": "Esecuzione del generatore", + "Generator progress": "Avanzamento del generatore", + "The run failed: {reason}": "L'esecuzione non è riuscita: {reason}", + "{placed} of {lessons} lessons placed": "{placed} lezioni su {lessons} collocate", + "Generate again": "Genera di nuovo", + "Generate": "Genera", + "Every lesson has a place.": "Ogni lezione ha un posto.", + "Lesson": "Lezione", + "No free period and room": "Nessuna ora né aula libera", + "Broken soft wishes": "Desideri flessibili non rispettati", + "Every soft wish is kept.": "Ogni desiderio flessibile è rispettato.", + "Wish": "Desiderio", + "Lessons": "Lezioni", + "Generating: {percent}% of the time budget used": "Generazione: {percent}% del tempo usato", + "Waiting to start": "In attesa di avvio", + "Published as draft lessons": "Pubblicato come lezioni in bozza", + "Could not read the scenario.": "Impossibile leggere lo scenario.", + "Could not start the run.": "Impossibile avviare l'esecuzione.", + "Scenarios": "Scenari", + "Timetable scenarios": "Scenari di orario", + "Scenario": "Scenario" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 65a51e67..393d319b 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", "Timetable": "Stonneplang", "Wishes": "Wënsch", - "Timetable wishes": "Stonneplangwënsch" + "Timetable wishes": "Stonneplangwënsch", + "Generator run": "Generatorlaf", + "Generator progress": "Fortschrëtt vum Generator", + "The run failed: {reason}": "De Laf ass feelgeschloen: {reason}", + "{placed} of {lessons} lessons placed": "{placed} vun {lessons} Stonne placéiert", + "Generate again": "Nach eng Kéier generéieren", + "Generate": "Generéieren", + "Every lesson has a place.": "All Stonn huet eng Plaz.", + "Lesson": "Stonn", + "No free period and room": "Keng fräi Stonn a kee fräie Sall", + "Broken soft wishes": "Verletzt flexibel Wënsch", + "Every soft wish is kept.": "All flexibel Wonsch gëtt agehalen.", + "Wish": "Wonsch", + "Lessons": "Stonnen", + "Generating: {percent}% of the time budget used": "Gëtt generéiert: {percent}% vun der Zäit gebraucht", + "Waiting to start": "Waart op de Start", + "Published as draft lessons": "Als Entworfsstonne publizéiert", + "Could not read the scenario.": "De Szenario konnt net gelies ginn.", + "Could not start the run.": "De Laf konnt net gestart ginn.", + "Scenarios": "Szenarien", + "Timetable scenarios": "Stonneplangszenarien", + "Scenario": "Szenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 6a87037e..c3ea58de 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", "Timetable": "Stonneplang", "Wishes": "Wënsch", - "Timetable wishes": "Stonneplangwënsch" + "Timetable wishes": "Stonneplangwënsch", + "Generator run": "Generatorlaf", + "Generator progress": "Fortschrëtt vum Generator", + "The run failed: {reason}": "De Laf ass feelgeschloen: {reason}", + "{placed} of {lessons} lessons placed": "{placed} vun {lessons} Stonne placéiert", + "Generate again": "Nach eng Kéier generéieren", + "Generate": "Generéieren", + "Every lesson has a place.": "All Stonn huet eng Plaz.", + "Lesson": "Stonn", + "No free period and room": "Keng fräi Stonn a kee fräie Sall", + "Broken soft wishes": "Verletzt flexibel Wënsch", + "Every soft wish is kept.": "All flexibel Wonsch gëtt agehalen.", + "Wish": "Wonsch", + "Lessons": "Stonnen", + "Generating: {percent}% of the time budget used": "Gëtt generéiert: {percent}% vun der Zäit gebraucht", + "Waiting to start": "Waart op de Start", + "Published as draft lessons": "Als Entworfsstonne publizéiert", + "Could not read the scenario.": "De Szenario konnt net gelies ginn.", + "Could not start the run.": "De Laf konnt net gestart ginn.", + "Scenarios": "Szenarien", + "Timetable scenarios": "Stonneplangszenarien", + "Scenario": "Szenario" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 423106ef..ad25e2dd 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", "Timetable": "Tvarkaraštis", "Wishes": "Pageidavimai", - "Timetable wishes": "Tvarkaraščio pageidavimai" + "Timetable wishes": "Tvarkaraščio pageidavimai", + "Generator run": "Generatoriaus vykdymas", + "Generator progress": "Generatoriaus eiga", + "The run failed: {reason}": "Vykdymas nepavyko: {reason}", + "{placed} of {lessons} lessons placed": "Išdėstyta {placed} iš {lessons} pamokų", + "Generate again": "Generuoti iš naujo", + "Generate": "Generuoti", + "Every lesson has a place.": "Kiekviena pamoka turi vietą.", + "Lesson": "Pamoka", + "No free period and room": "Nėra laisvos pamokos ir patalpos", + "Broken soft wishes": "Pažeisti lankstūs pageidavimai", + "Every soft wish is kept.": "Kiekvieno lankstaus pageidavimo laikomasi.", + "Wish": "Pageidavimas", + "Lessons": "Pamokos", + "Generating: {percent}% of the time budget used": "Generuojama: išnaudota {percent}% laiko", + "Waiting to start": "Laukiama pradžios", + "Published as draft lessons": "Paskelbta kaip pamokų juodraščiai", + "Could not read the scenario.": "Scenarijaus perskaityti nepavyko.", + "Could not start the run.": "Vykdymo pradėti nepavyko.", + "Scenarios": "Scenarijai", + "Timetable scenarios": "Tvarkaraščio scenarijai", + "Scenario": "Scenarijus" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index 2cc512b0..3035ffcb 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", "Timetable": "Tvarkaraštis", "Wishes": "Pageidavimai", - "Timetable wishes": "Tvarkaraščio pageidavimai" + "Timetable wishes": "Tvarkaraščio pageidavimai", + "Generator run": "Generatoriaus vykdymas", + "Generator progress": "Generatoriaus eiga", + "The run failed: {reason}": "Vykdymas nepavyko: {reason}", + "{placed} of {lessons} lessons placed": "Išdėstyta {placed} iš {lessons} pamokų", + "Generate again": "Generuoti iš naujo", + "Generate": "Generuoti", + "Every lesson has a place.": "Kiekviena pamoka turi vietą.", + "Lesson": "Pamoka", + "No free period and room": "Nėra laisvos pamokos ir patalpos", + "Broken soft wishes": "Pažeisti lankstūs pageidavimai", + "Every soft wish is kept.": "Kiekvieno lankstaus pageidavimo laikomasi.", + "Wish": "Pageidavimas", + "Lessons": "Pamokos", + "Generating: {percent}% of the time budget used": "Generuojama: išnaudota {percent}% laiko", + "Waiting to start": "Laukiama pradžios", + "Published as draft lessons": "Paskelbta kaip pamokų juodraščiai", + "Could not read the scenario.": "Scenarijaus perskaityti nepavyko.", + "Could not start the run.": "Vykdymo pradėti nepavyko.", + "Scenarios": "Scenarijai", + "Timetable scenarios": "Tvarkaraščio scenarijai", + "Scenario": "Scenarijus" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 0c631d2d..4675c34d 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", "Timetable": "Stundu saraksts", "Wishes": "Vēlmes", - "Timetable wishes": "Stundu saraksta vēlmes" + "Timetable wishes": "Stundu saraksta vēlmes", + "Generator run": "Ģeneratora palaišana", + "Generator progress": "Ģeneratora progress", + "The run failed: {reason}": "Palaišana neizdevās: {reason}", + "{placed} of {lessons} lessons placed": "Izvietotas {placed} no {lessons} stundām", + "Generate again": "Ģenerēt vēlreiz", + "Generate": "Ģenerēt", + "Every lesson has a place.": "Katrai stundai ir vieta.", + "Lesson": "Stunda", + "No free period and room": "Nav brīvas stundas un telpas", + "Broken soft wishes": "Pārkāptās elastīgās vēlmes", + "Every soft wish is kept.": "Katra elastīgā vēlme ir ievērota.", + "Wish": "Vēlme", + "Lessons": "Stundas", + "Generating: {percent}% of the time budget used": "Ģenerē: izmantoti {percent}% laika", + "Waiting to start": "Gaida sākumu", + "Published as draft lessons": "Publicēts kā stundu melnraksti", + "Could not read the scenario.": "Scenāriju neizdevās nolasīt.", + "Could not start the run.": "Palaišanu neizdevās sākt.", + "Scenarios": "Scenāriji", + "Timetable scenarios": "Stundu saraksta scenāriji", + "Scenario": "Scenārijs" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 0804406e..74783f55 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", "Timetable": "Stundu saraksts", "Wishes": "Vēlmes", - "Timetable wishes": "Stundu saraksta vēlmes" + "Timetable wishes": "Stundu saraksta vēlmes", + "Generator run": "Ģeneratora palaišana", + "Generator progress": "Ģeneratora progress", + "The run failed: {reason}": "Palaišana neizdevās: {reason}", + "{placed} of {lessons} lessons placed": "Izvietotas {placed} no {lessons} stundām", + "Generate again": "Ģenerēt vēlreiz", + "Generate": "Ģenerēt", + "Every lesson has a place.": "Katrai stundai ir vieta.", + "Lesson": "Stunda", + "No free period and room": "Nav brīvas stundas un telpas", + "Broken soft wishes": "Pārkāptās elastīgās vēlmes", + "Every soft wish is kept.": "Katra elastīgā vēlme ir ievērota.", + "Wish": "Vēlme", + "Lessons": "Stundas", + "Generating: {percent}% of the time budget used": "Ģenerē: izmantoti {percent}% laika", + "Waiting to start": "Gaida sākumu", + "Published as draft lessons": "Publicēts kā stundu melnraksti", + "Could not read the scenario.": "Scenāriju neizdevās nolasīt.", + "Could not start the run.": "Palaišanu neizdevās sākt.", + "Scenarios": "Scenāriji", + "Timetable scenarios": "Stundu saraksta scenāriji", + "Scenario": "Scenārijs" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index a7a889cd..8f1e6ec4 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", "Timetable": "Распоред", "Wishes": "Желби", - "Timetable wishes": "Желби за распоредот" + "Timetable wishes": "Желби за распоредот", + "Generator run": "Извршување на генераторот", + "Generator progress": "Напредок на генераторот", + "The run failed: {reason}": "Извршувањето не успеа: {reason}", + "{placed} of {lessons} lessons placed": "Сместени {placed} од {lessons} часови", + "Generate again": "Генерирај повторно", + "Generate": "Генерирај", + "Every lesson has a place.": "Секој час има место.", + "Lesson": "Час", + "No free period and room": "Нема слободен час и просторија", + "Broken soft wishes": "Прекршени флексибилни желби", + "Every soft wish is kept.": "Секоја флексибилна желба е почитувана.", + "Wish": "Желба", + "Lessons": "Часови", + "Generating: {percent}% of the time budget used": "Се генерира: искористено {percent}% од времето", + "Waiting to start": "Чека почеток", + "Published as draft lessons": "Објавено како нацрт-часови", + "Could not read the scenario.": "Сценариото не може да се прочита.", + "Could not start the run.": "Извршувањето не може да започне.", + "Scenarios": "Сценарија", + "Timetable scenarios": "Сценарија за распоредот", + "Scenario": "Сценарио" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 11492adf..7d3259db 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", "Timetable": "Распоред", "Wishes": "Желби", - "Timetable wishes": "Желби за распоредот" + "Timetable wishes": "Желби за распоредот", + "Generator run": "Извршување на генераторот", + "Generator progress": "Напредок на генераторот", + "The run failed: {reason}": "Извршувањето не успеа: {reason}", + "{placed} of {lessons} lessons placed": "Сместени {placed} од {lessons} часови", + "Generate again": "Генерирај повторно", + "Generate": "Генерирај", + "Every lesson has a place.": "Секој час има место.", + "Lesson": "Час", + "No free period and room": "Нема слободен час и просторија", + "Broken soft wishes": "Прекршени флексибилни желби", + "Every soft wish is kept.": "Секоја флексибилна желба е почитувана.", + "Wish": "Желба", + "Lessons": "Часови", + "Generating: {percent}% of the time budget used": "Се генерира: искористено {percent}% од времето", + "Waiting to start": "Чека почеток", + "Published as draft lessons": "Објавено како нацрт-часови", + "Could not read the scenario.": "Сценариото не може да се прочита.", + "Could not start the run.": "Извршувањето не може да започне.", + "Scenarios": "Сценарија", + "Timetable scenarios": "Сценарија за распоредот", + "Scenario": "Сценарио" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index b965074f..10b83ab6 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", "Timetable": "Orarju", "Wishes": "Xewqat", - "Timetable wishes": "Xewqat għall-orarju" + "Timetable wishes": "Xewqat għall-orarju", + "Generator run": "Tħaddim tal-ġeneratur", + "Generator progress": "Progress tal-ġeneratur", + "The run failed: {reason}": "It-tħaddim falla: {reason}", + "{placed} of {lessons} lessons placed": "{placed} minn {lessons} lezzjonijiet imqiegħda", + "Generate again": "Iġġenera mill-ġdid", + "Generate": "Iġġenera", + "Every lesson has a place.": "Kull lezzjoni għandha post.", + "Lesson": "Lezzjoni", + "No free period and room": "L-ebda perjodu u kamra ħielsa", + "Broken soft wishes": "Xewqat ratba miksura", + "Every soft wish is kept.": "Kull xewqa ratba hi miżmuma.", + "Wish": "Xewqa", + "Lessons": "Lezzjonijiet", + "Generating: {percent}% of the time budget used": "Qed jiġġenera: intuża {percent}% tal-ħin", + "Waiting to start": "Qed jistenna biex jibda", + "Published as draft lessons": "Ippubblikat bħala lezzjonijiet abbozz", + "Could not read the scenario.": "Ma setax jinqara x-xenarju.", + "Could not start the run.": "Ma setax jibda t-tħaddim.", + "Scenarios": "Xenarji", + "Timetable scenarios": "Xenarji tal-orarju", + "Scenario": "Xenarju" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 449db2a0..52573b2d 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", "Timetable": "Orarju", "Wishes": "Xewqat", - "Timetable wishes": "Xewqat għall-orarju" + "Timetable wishes": "Xewqat għall-orarju", + "Generator run": "Tħaddim tal-ġeneratur", + "Generator progress": "Progress tal-ġeneratur", + "The run failed: {reason}": "It-tħaddim falla: {reason}", + "{placed} of {lessons} lessons placed": "{placed} minn {lessons} lezzjonijiet imqiegħda", + "Generate again": "Iġġenera mill-ġdid", + "Generate": "Iġġenera", + "Every lesson has a place.": "Kull lezzjoni għandha post.", + "Lesson": "Lezzjoni", + "No free period and room": "L-ebda perjodu u kamra ħielsa", + "Broken soft wishes": "Xewqat ratba miksura", + "Every soft wish is kept.": "Kull xewqa ratba hi miżmuma.", + "Wish": "Xewqa", + "Lessons": "Lezzjonijiet", + "Generating: {percent}% of the time budget used": "Qed jiġġenera: intuża {percent}% tal-ħin", + "Waiting to start": "Qed jistenna biex jibda", + "Published as draft lessons": "Ippubblikat bħala lezzjonijiet abbozz", + "Could not read the scenario.": "Ma setax jinqara x-xenarju.", + "Could not start the run.": "Ma setax jibda t-tħaddim.", + "Scenarios": "Xenarji", + "Timetable scenarios": "Xenarji tal-orarju", + "Scenario": "Xenarju" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index ba08420d..e589b7f7 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", "Timetable": "Timeplan", "Wishes": "Ønsker", - "Timetable wishes": "Timeplanønsker" + "Timetable wishes": "Timeplanønsker", + "Generator run": "Generatorkjøring", + "Generator progress": "Generatorens fremdrift", + "The run failed: {reason}": "Kjøringen mislyktes: {reason}", + "{placed} of {lessons} lessons placed": "{placed} av {lessons} timer plassert", + "Generate again": "Generer på nytt", + "Generate": "Generer", + "Every lesson has a place.": "Hver time har en plass.", + "Lesson": "Time", + "No free period and room": "Ingen ledig time og rom", + "Broken soft wishes": "Brutte myke ønsker", + "Every soft wish is kept.": "Hvert mykt ønske er holdt.", + "Wish": "Ønske", + "Lessons": "Timer", + "Generating: {percent}% of the time budget used": "Genererer: {percent} % av tiden brukt", + "Waiting to start": "Venter på start", + "Published as draft lessons": "Publisert som utkast til timer", + "Could not read the scenario.": "Kunne ikke lese scenariet.", + "Could not start the run.": "Kunne ikke starte kjøringen.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Timeplanscenarier", + "Scenario": "Scenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 6315a70d..09528a88 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", "Timetable": "Timeplan", "Wishes": "Ønsker", - "Timetable wishes": "Timeplanønsker" + "Timetable wishes": "Timeplanønsker", + "Generator run": "Generatorkjøring", + "Generator progress": "Generatorens fremdrift", + "The run failed: {reason}": "Kjøringen mislyktes: {reason}", + "{placed} of {lessons} lessons placed": "{placed} av {lessons} timer plassert", + "Generate again": "Generer på nytt", + "Generate": "Generer", + "Every lesson has a place.": "Hver time har en plass.", + "Lesson": "Time", + "No free period and room": "Ingen ledig time og rom", + "Broken soft wishes": "Brutte myke ønsker", + "Every soft wish is kept.": "Hvert mykt ønske er holdt.", + "Wish": "Ønske", + "Lessons": "Timer", + "Generating: {percent}% of the time budget used": "Genererer: {percent} % av tiden brukt", + "Waiting to start": "Venter på start", + "Published as draft lessons": "Publisert som utkast til timer", + "Could not read the scenario.": "Kunne ikke lese scenariet.", + "Could not start the run.": "Kunne ikke starte kjøringen.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Timeplanscenarier", + "Scenario": "Scenario" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 5b104625..653aaa36 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1037,7 +1037,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", "Timetable": "Rooster", "Wishes": "Wensen", - "Timetable wishes": "Roosterwensen" + "Timetable wishes": "Roosterwensen", + "Generator run": "Generatorrun", + "Generator progress": "Voortgang van de generator", + "The run failed: {reason}": "De run is mislukt: {reason}", + "{placed} of {lessons} lessons placed": "{placed} van {lessons} lessen geplaatst", + "Generate again": "Opnieuw genereren", + "Generate": "Genereren", + "Every lesson has a place.": "Elke les heeft een plek.", + "Lesson": "Les", + "No free period and room": "Geen vrij lesuur en lokaal", + "Broken soft wishes": "Geschonden zachte wensen", + "Every soft wish is kept.": "Elke zachte wens is gehouden.", + "Wish": "Wens", + "Lessons": "Lessen", + "Generating: {percent}% of the time budget used": "Bezig met genereren: {percent}% van de tijd gebruikt", + "Waiting to start": "Wacht op start", + "Published as draft lessons": "Gepubliceerd als conceptlessen", + "Could not read the scenario.": "Kon het scenario niet lezen.", + "Could not start the run.": "Kon de run niet starten.", + "Scenarios": "Scenario's", + "Timetable scenarios": "Roosterscenario's", + "Scenario": "Scenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 188ed3c1..00faa044 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1036,7 +1036,28 @@ "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", "Timetable": "Rooster", "Wishes": "Wensen", - "Timetable wishes": "Roosterwensen" + "Timetable wishes": "Roosterwensen", + "Generator run": "Generatorrun", + "Generator progress": "Voortgang van de generator", + "The run failed: {reason}": "De run is mislukt: {reason}", + "{placed} of {lessons} lessons placed": "{placed} van {lessons} lessen geplaatst", + "Generate again": "Opnieuw genereren", + "Generate": "Genereren", + "Every lesson has a place.": "Elke les heeft een plek.", + "Lesson": "Les", + "No free period and room": "Geen vrij lesuur en lokaal", + "Broken soft wishes": "Geschonden zachte wensen", + "Every soft wish is kept.": "Elke zachte wens is gehouden.", + "Wish": "Wens", + "Lessons": "Lessen", + "Generating: {percent}% of the time budget used": "Bezig met genereren: {percent}% van de tijd gebruikt", + "Waiting to start": "Wacht op start", + "Published as draft lessons": "Gepubliceerd als conceptlessen", + "Could not read the scenario.": "Kon het scenario niet lezen.", + "Could not start the run.": "Kon de run niet starten.", + "Scenarios": "Scenario's", + "Timetable scenarios": "Roosterscenario's", + "Scenario": "Scenario" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 20d86374..75f9e257 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", "Timetable": "Plan lekcji", "Wishes": "Życzenia", - "Timetable wishes": "Życzenia do planu lekcji" + "Timetable wishes": "Życzenia do planu lekcji", + "Generator run": "Przebieg generatora", + "Generator progress": "Postęp generatora", + "The run failed: {reason}": "Przebieg się nie powiódł: {reason}", + "{placed} of {lessons} lessons placed": "Umieszczono {placed} z {lessons} lekcji", + "Generate again": "Generuj ponownie", + "Generate": "Generuj", + "Every lesson has a place.": "Każda lekcja ma miejsce.", + "Lesson": "Lekcja", + "No free period and room": "Brak wolnej godziny i sali", + "Broken soft wishes": "Niedotrzymane miękkie życzenia", + "Every soft wish is kept.": "Każde miękkie życzenie jest dotrzymane.", + "Wish": "Życzenie", + "Lessons": "Lekcje", + "Generating: {percent}% of the time budget used": "Generowanie: wykorzystano {percent}% czasu", + "Waiting to start": "Oczekuje na start", + "Published as draft lessons": "Opublikowano jako szkice lekcji", + "Could not read the scenario.": "Nie udało się odczytać scenariusza.", + "Could not start the run.": "Nie udało się uruchomić przebiegu.", + "Scenarios": "Scenariusze", + "Timetable scenarios": "Scenariusze planu lekcji", + "Scenario": "Scenariusz" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index b3f28009..f7e08281 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", "Timetable": "Plan lekcji", "Wishes": "Życzenia", - "Timetable wishes": "Życzenia do planu lekcji" + "Timetable wishes": "Życzenia do planu lekcji", + "Generator run": "Przebieg generatora", + "Generator progress": "Postęp generatora", + "The run failed: {reason}": "Przebieg się nie powiódł: {reason}", + "{placed} of {lessons} lessons placed": "Umieszczono {placed} z {lessons} lekcji", + "Generate again": "Generuj ponownie", + "Generate": "Generuj", + "Every lesson has a place.": "Każda lekcja ma miejsce.", + "Lesson": "Lekcja", + "No free period and room": "Brak wolnej godziny i sali", + "Broken soft wishes": "Niedotrzymane miękkie życzenia", + "Every soft wish is kept.": "Każde miękkie życzenie jest dotrzymane.", + "Wish": "Życzenie", + "Lessons": "Lekcje", + "Generating: {percent}% of the time budget used": "Generowanie: wykorzystano {percent}% czasu", + "Waiting to start": "Oczekuje na start", + "Published as draft lessons": "Opublikowano jako szkice lekcji", + "Could not read the scenario.": "Nie udało się odczytać scenariusza.", + "Could not start the run.": "Nie udało się uruchomić przebiegu.", + "Scenarios": "Scenariusze", + "Timetable scenarios": "Scenariusze planu lekcji", + "Scenario": "Scenariusz" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index 5d038eaf..cb8ac8bf 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", "Timetable": "Horário", "Wishes": "Desejos", - "Timetable wishes": "Desejos de horário" + "Timetable wishes": "Desejos de horário", + "Generator run": "Execução do gerador", + "Generator progress": "Progresso do gerador", + "The run failed: {reason}": "A execução falhou: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} aulas colocadas", + "Generate again": "Gerar novamente", + "Generate": "Gerar", + "Every lesson has a place.": "Cada aula tem um lugar.", + "Lesson": "Aula", + "No free period and room": "Sem tempo nem sala livre", + "Broken soft wishes": "Desejos flexíveis não cumpridos", + "Every soft wish is kept.": "Cada desejo flexível é cumprido.", + "Wish": "Desejo", + "Lessons": "Aulas", + "Generating: {percent}% of the time budget used": "A gerar: {percent}% do tempo usado", + "Waiting to start": "A aguardar início", + "Published as draft lessons": "Publicado como aulas em rascunho", + "Could not read the scenario.": "Não foi possível ler o cenário.", + "Could not start the run.": "Não foi possível iniciar a execução.", + "Scenarios": "Cenários", + "Timetable scenarios": "Cenários de horário", + "Scenario": "Cenário" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index d78a0e90..03a75d71 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", "Timetable": "Horário", "Wishes": "Desejos", - "Timetable wishes": "Desejos de horário" + "Timetable wishes": "Desejos de horário", + "Generator run": "Execução do gerador", + "Generator progress": "Progresso do gerador", + "The run failed: {reason}": "A execução falhou: {reason}", + "{placed} of {lessons} lessons placed": "{placed} de {lessons} aulas colocadas", + "Generate again": "Gerar novamente", + "Generate": "Gerar", + "Every lesson has a place.": "Cada aula tem um lugar.", + "Lesson": "Aula", + "No free period and room": "Sem tempo nem sala livre", + "Broken soft wishes": "Desejos flexíveis não cumpridos", + "Every soft wish is kept.": "Cada desejo flexível é cumprido.", + "Wish": "Desejo", + "Lessons": "Aulas", + "Generating: {percent}% of the time budget used": "A gerar: {percent}% do tempo usado", + "Waiting to start": "A aguardar início", + "Published as draft lessons": "Publicado como aulas em rascunho", + "Could not read the scenario.": "Não foi possível ler o cenário.", + "Could not start the run.": "Não foi possível iniciar a execução.", + "Scenarios": "Cenários", + "Timetable scenarios": "Cenários de horário", + "Scenario": "Cenário" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 39114072..554a534b 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", "Timetable": "Plan d'uras", "Wishes": "Giavischs", - "Timetable wishes": "Giavischs per il plan d'uras" + "Timetable wishes": "Giavischs per il plan d'uras", + "Generator run": "Lantschada dal generatur", + "Generator progress": "Progress dal generatur", + "The run failed: {reason}": "La lantschada n'è betg reussida: {reason}", + "{placed} of {lessons} lessons placed": "{placed} da {lessons} lecziuns plazzadas", + "Generate again": "Generar danovamain", + "Generate": "Generar", + "Every lesson has a place.": "Mintga lecziun ha in plaz.", + "Lesson": "Lecziun", + "No free period and room": "Nagina ura e nagina stanza libra", + "Broken soft wishes": "Giavischs flexibels violads", + "Every soft wish is kept.": "Mintga giavisch flexibel vegn resguardà.", + "Wish": "Giavisch", + "Lessons": "Lecziuns", + "Generating: {percent}% of the time budget used": "Generescha: {percent}% dal temp duvrà", + "Waiting to start": "Spetga sin il cumenzament", + "Published as draft lessons": "Publitgà sco sbozs da lecziuns", + "Could not read the scenario.": "Il scenari n'ha betg pudì vegnir legì.", + "Could not start the run.": "La lantschada n'ha betg pudì vegnir cumenzada.", + "Scenarios": "Scenaris", + "Timetable scenarios": "Scenaris dal plan d'uras", + "Scenario": "Scenari" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 6f85826f..9ff41bf9 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", "Timetable": "Plan d'uras", "Wishes": "Giavischs", - "Timetable wishes": "Giavischs per il plan d'uras" + "Timetable wishes": "Giavischs per il plan d'uras", + "Generator run": "Lantschada dal generatur", + "Generator progress": "Progress dal generatur", + "The run failed: {reason}": "La lantschada n'è betg reussida: {reason}", + "{placed} of {lessons} lessons placed": "{placed} da {lessons} lecziuns plazzadas", + "Generate again": "Generar danovamain", + "Generate": "Generar", + "Every lesson has a place.": "Mintga lecziun ha in plaz.", + "Lesson": "Lecziun", + "No free period and room": "Nagina ura e nagina stanza libra", + "Broken soft wishes": "Giavischs flexibels violads", + "Every soft wish is kept.": "Mintga giavisch flexibel vegn resguardà.", + "Wish": "Giavisch", + "Lessons": "Lecziuns", + "Generating: {percent}% of the time budget used": "Generescha: {percent}% dal temp duvrà", + "Waiting to start": "Spetga sin il cumenzament", + "Published as draft lessons": "Publitgà sco sbozs da lecziuns", + "Could not read the scenario.": "Il scenari n'ha betg pudì vegnir legì.", + "Could not start the run.": "La lantschada n'ha betg pudì vegnir cumenzada.", + "Scenarios": "Scenaris", + "Timetable scenarios": "Scenaris dal plan d'uras", + "Scenario": "Scenari" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 8fdfc0f5..b6631d2e 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", "Timetable": "Orar", "Wishes": "Dorințe", - "Timetable wishes": "Dorințe pentru orar" + "Timetable wishes": "Dorințe pentru orar", + "Generator run": "Rularea generatorului", + "Generator progress": "Progresul generatorului", + "The run failed: {reason}": "Rularea a eșuat: {reason}", + "{placed} of {lessons} lessons placed": "{placed} din {lessons} ore plasate", + "Generate again": "Generează din nou", + "Generate": "Generează", + "Every lesson has a place.": "Fiecare oră are un loc.", + "Lesson": "Oră", + "No free period and room": "Nicio oră și sală liberă", + "Broken soft wishes": "Dorințe flexibile încălcate", + "Every soft wish is kept.": "Fiecare dorință flexibilă este respectată.", + "Wish": "Dorință", + "Lessons": "Ore", + "Generating: {percent}% of the time budget used": "Se generează: {percent}% din timp folosit", + "Waiting to start": "Așteaptă pornirea", + "Published as draft lessons": "Publicat ca ore ciornă", + "Could not read the scenario.": "Scenariul nu a putut fi citit.", + "Could not start the run.": "Rularea nu a putut porni.", + "Scenarios": "Scenarii", + "Timetable scenarios": "Scenarii de orar", + "Scenario": "Scenariu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index 47c184c1..40513414 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", "Timetable": "Orar", "Wishes": "Dorințe", - "Timetable wishes": "Dorințe pentru orar" + "Timetable wishes": "Dorințe pentru orar", + "Generator run": "Rularea generatorului", + "Generator progress": "Progresul generatorului", + "The run failed: {reason}": "Rularea a eșuat: {reason}", + "{placed} of {lessons} lessons placed": "{placed} din {lessons} ore plasate", + "Generate again": "Generează din nou", + "Generate": "Generează", + "Every lesson has a place.": "Fiecare oră are un loc.", + "Lesson": "Oră", + "No free period and room": "Nicio oră și sală liberă", + "Broken soft wishes": "Dorințe flexibile încălcate", + "Every soft wish is kept.": "Fiecare dorință flexibilă este respectată.", + "Wish": "Dorință", + "Lessons": "Ore", + "Generating: {percent}% of the time budget used": "Se generează: {percent}% din timp folosit", + "Waiting to start": "Așteaptă pornirea", + "Published as draft lessons": "Publicat ca ore ciornă", + "Could not read the scenario.": "Scenariul nu a putut fi citit.", + "Could not start the run.": "Rularea nu a putut porni.", + "Scenarios": "Scenarii", + "Timetable scenarios": "Scenarii de orar", + "Scenario": "Scenariu" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index 7df38c78..53c172bf 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", "Timetable": "Расписание", "Wishes": "Пожелания", - "Timetable wishes": "Пожелания к расписанию" + "Timetable wishes": "Пожелания к расписанию", + "Generator run": "Запуск генератора", + "Generator progress": "Ход генератора", + "The run failed: {reason}": "Запуск не удался: {reason}", + "{placed} of {lessons} lessons placed": "Размещено {placed} из {lessons} уроков", + "Generate again": "Сгенерировать снова", + "Generate": "Сгенерировать", + "Every lesson has a place.": "У каждого урока есть место.", + "Lesson": "Урок", + "No free period and room": "Нет свободного урока и кабинета", + "Broken soft wishes": "Нарушенные гибкие пожелания", + "Every soft wish is kept.": "Каждое гибкое пожелание соблюдено.", + "Wish": "Пожелание", + "Lessons": "Уроки", + "Generating: {percent}% of the time budget used": "Генерация: использовано {percent}% времени", + "Waiting to start": "Ожидает запуска", + "Published as draft lessons": "Опубликовано как черновики уроков", + "Could not read the scenario.": "Не удалось прочитать сценарий.", + "Could not start the run.": "Не удалось начать запуск.", + "Scenarios": "Сценарии", + "Timetable scenarios": "Сценарии расписания", + "Scenario": "Сценарий" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 8ee034a6..7b3c2609 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", "Timetable": "Расписание", "Wishes": "Пожелания", - "Timetable wishes": "Пожелания к расписанию" + "Timetable wishes": "Пожелания к расписанию", + "Generator run": "Запуск генератора", + "Generator progress": "Ход генератора", + "The run failed: {reason}": "Запуск не удался: {reason}", + "{placed} of {lessons} lessons placed": "Размещено {placed} из {lessons} уроков", + "Generate again": "Сгенерировать снова", + "Generate": "Сгенерировать", + "Every lesson has a place.": "У каждого урока есть место.", + "Lesson": "Урок", + "No free period and room": "Нет свободного урока и кабинета", + "Broken soft wishes": "Нарушенные гибкие пожелания", + "Every soft wish is kept.": "Каждое гибкое пожелание соблюдено.", + "Wish": "Пожелание", + "Lessons": "Уроки", + "Generating: {percent}% of the time budget used": "Генерация: использовано {percent}% времени", + "Waiting to start": "Ожидает запуска", + "Published as draft lessons": "Опубликовано как черновики уроков", + "Could not read the scenario.": "Не удалось прочитать сценарий.", + "Could not start the run.": "Не удалось начать запуск.", + "Scenarios": "Сценарии", + "Timetable scenarios": "Сценарии расписания", + "Scenario": "Сценарий" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index 9f055f7d..8a9a73bc 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", "Timetable": "Rozvrh", "Wishes": "Želania", - "Timetable wishes": "Želania k rozvrhu" + "Timetable wishes": "Želania k rozvrhu", + "Generator run": "Beh generátora", + "Generator progress": "Priebeh generátora", + "The run failed: {reason}": "Beh zlyhal: {reason}", + "{placed} of {lessons} lessons placed": "Umiestnených {placed} z {lessons} hodín", + "Generate again": "Generovať znova", + "Generate": "Generovať", + "Every lesson has a place.": "Každá hodina má miesto.", + "Lesson": "Hodina", + "No free period and room": "Žiadna voľná hodina a učebňa", + "Broken soft wishes": "Porušené mäkké želania", + "Every soft wish is kept.": "Každé mäkké želanie je dodržané.", + "Wish": "Želanie", + "Lessons": "Hodiny", + "Generating: {percent}% of the time budget used": "Generovanie: využitých {percent} % času", + "Waiting to start": "Čaká na spustenie", + "Published as draft lessons": "Zverejnené ako koncepty hodín", + "Could not read the scenario.": "Scenár sa nepodarilo načítať.", + "Could not start the run.": "Beh sa nepodarilo spustiť.", + "Scenarios": "Scenáre", + "Timetable scenarios": "Scenáre rozvrhu", + "Scenario": "Scenár" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index f461d2a9..0ee3c056 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", "Timetable": "Rozvrh", "Wishes": "Želania", - "Timetable wishes": "Želania k rozvrhu" + "Timetable wishes": "Želania k rozvrhu", + "Generator run": "Beh generátora", + "Generator progress": "Priebeh generátora", + "The run failed: {reason}": "Beh zlyhal: {reason}", + "{placed} of {lessons} lessons placed": "Umiestnených {placed} z {lessons} hodín", + "Generate again": "Generovať znova", + "Generate": "Generovať", + "Every lesson has a place.": "Každá hodina má miesto.", + "Lesson": "Hodina", + "No free period and room": "Žiadna voľná hodina a učebňa", + "Broken soft wishes": "Porušené mäkké želania", + "Every soft wish is kept.": "Každé mäkké želanie je dodržané.", + "Wish": "Želanie", + "Lessons": "Hodiny", + "Generating: {percent}% of the time budget used": "Generovanie: využitých {percent} % času", + "Waiting to start": "Čaká na spustenie", + "Published as draft lessons": "Zverejnené ako koncepty hodín", + "Could not read the scenario.": "Scenár sa nepodarilo načítať.", + "Could not start the run.": "Beh sa nepodarilo spustiť.", + "Scenarios": "Scenáre", + "Timetable scenarios": "Scenáre rozvrhu", + "Scenario": "Scenár" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index a454a528..31409b3c 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", "Timetable": "Urnik", "Wishes": "Želje", - "Timetable wishes": "Želje za urnik" + "Timetable wishes": "Želje za urnik", + "Generator run": "Zagon generatorja", + "Generator progress": "Napredek generatorja", + "The run failed: {reason}": "Zagon ni uspel: {reason}", + "{placed} of {lessons} lessons placed": "Umeščenih {placed} od {lessons} ur", + "Generate again": "Ustvari znova", + "Generate": "Ustvari", + "Every lesson has a place.": "Vsaka ura ima mesto.", + "Lesson": "Ura", + "No free period and room": "Ni proste ure in učilnice", + "Broken soft wishes": "Kršene prožne želje", + "Every soft wish is kept.": "Vsaka prožna želja je upoštevana.", + "Wish": "Želja", + "Lessons": "Ure", + "Generating: {percent}% of the time budget used": "Ustvarjanje: porabljenih {percent} % časa", + "Waiting to start": "Čaka na začetek", + "Published as draft lessons": "Objavljeno kot osnutki ur", + "Could not read the scenario.": "Scenarija ni bilo mogoče prebrati.", + "Could not start the run.": "Zagona ni bilo mogoče začeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji urnika", + "Scenario": "Scenarij" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 437c3f13..cf354b1a 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", "Timetable": "Urnik", "Wishes": "Želje", - "Timetable wishes": "Želje za urnik" + "Timetable wishes": "Želje za urnik", + "Generator run": "Zagon generatorja", + "Generator progress": "Napredek generatorja", + "The run failed: {reason}": "Zagon ni uspel: {reason}", + "{placed} of {lessons} lessons placed": "Umeščenih {placed} od {lessons} ur", + "Generate again": "Ustvari znova", + "Generate": "Ustvari", + "Every lesson has a place.": "Vsaka ura ima mesto.", + "Lesson": "Ura", + "No free period and room": "Ni proste ure in učilnice", + "Broken soft wishes": "Kršene prožne želje", + "Every soft wish is kept.": "Vsaka prožna želja je upoštevana.", + "Wish": "Želja", + "Lessons": "Ure", + "Generating: {percent}% of the time budget used": "Ustvarjanje: porabljenih {percent} % časa", + "Waiting to start": "Čaka na začetek", + "Published as draft lessons": "Objavljeno kot osnutki ur", + "Could not read the scenario.": "Scenarija ni bilo mogoče prebrati.", + "Could not start the run.": "Zagona ni bilo mogoče začeti.", + "Scenarios": "Scenariji", + "Timetable scenarios": "Scenariji urnika", + "Scenario": "Scenarij" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 90d32143..690e2a89 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", "Timetable": "Orari", "Wishes": "Dëshirat", - "Timetable wishes": "Dëshirat për orarin" + "Timetable wishes": "Dëshirat për orarin", + "Generator run": "Ekzekutimi i gjeneratorit", + "Generator progress": "Ecuria e gjeneratorit", + "The run failed: {reason}": "Ekzekutimi dështoi: {reason}", + "{placed} of {lessons} lessons placed": "U vendosën {placed} nga {lessons} orë mësimi", + "Generate again": "Gjenero sërish", + "Generate": "Gjenero", + "Every lesson has a place.": "Çdo orë mësimi ka një vend.", + "Lesson": "Orë mësimi", + "No free period and room": "Nuk ka orë dhe sallë të lirë", + "Broken soft wishes": "Dëshira të buta të shkelura", + "Every soft wish is kept.": "Çdo dëshirë e butë respektohet.", + "Wish": "Dëshirë", + "Lessons": "Orë mësimi", + "Generating: {percent}% of the time budget used": "Po gjenerohet: u përdor {percent}% e kohës", + "Waiting to start": "Në pritje të nisjes", + "Published as draft lessons": "U publikua si orë mësimi në draft", + "Could not read the scenario.": "Skenari nuk u lexua dot.", + "Could not start the run.": "Ekzekutimi nuk u nis dot.", + "Scenarios": "Skenarë", + "Timetable scenarios": "Skenarë orari", + "Scenario": "Skenar" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 140814cb..20e293f5 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", "Timetable": "Orari", "Wishes": "Dëshirat", - "Timetable wishes": "Dëshirat për orarin" + "Timetable wishes": "Dëshirat për orarin", + "Generator run": "Ekzekutimi i gjeneratorit", + "Generator progress": "Ecuria e gjeneratorit", + "The run failed: {reason}": "Ekzekutimi dështoi: {reason}", + "{placed} of {lessons} lessons placed": "U vendosën {placed} nga {lessons} orë mësimi", + "Generate again": "Gjenero sërish", + "Generate": "Gjenero", + "Every lesson has a place.": "Çdo orë mësimi ka një vend.", + "Lesson": "Orë mësimi", + "No free period and room": "Nuk ka orë dhe sallë të lirë", + "Broken soft wishes": "Dëshira të buta të shkelura", + "Every soft wish is kept.": "Çdo dëshirë e butë respektohet.", + "Wish": "Dëshirë", + "Lessons": "Orë mësimi", + "Generating: {percent}% of the time budget used": "Po gjenerohet: u përdor {percent}% e kohës", + "Waiting to start": "Në pritje të nisjes", + "Published as draft lessons": "U publikua si orë mësimi në draft", + "Could not read the scenario.": "Skenari nuk u lexua dot.", + "Could not start the run.": "Ekzekutimi nuk u nis dot.", + "Scenarios": "Skenarë", + "Timetable scenarios": "Skenarë orari", + "Scenario": "Skenar" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index d2a0cc59..58890fe1 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", "Timetable": "Распоред", "Wishes": "Жеље", - "Timetable wishes": "Жеље за распоред" + "Timetable wishes": "Жеље за распоред", + "Generator run": "Покретање генератора", + "Generator progress": "Напредак генератора", + "The run failed: {reason}": "Покретање није успело: {reason}", + "{placed} of {lessons} lessons placed": "Смештено {placed} од {lessons} часова", + "Generate again": "Генериши поново", + "Generate": "Генериши", + "Every lesson has a place.": "Сваки час има место.", + "Lesson": "Час", + "No free period and room": "Нема слободног часа и учионице", + "Broken soft wishes": "Прекршене флексибилне жеље", + "Every soft wish is kept.": "Свака флексибилна жеља је поштована.", + "Wish": "Жеља", + "Lessons": "Часови", + "Generating: {percent}% of the time budget used": "Генерисање: искоришћено {percent}% времена", + "Waiting to start": "Чека почетак", + "Published as draft lessons": "Објављено као нацрти часова", + "Could not read the scenario.": "Сценарио није могуће прочитати.", + "Could not start the run.": "Покретање није могуће започети.", + "Scenarios": "Сценарији", + "Timetable scenarios": "Сценарији распореда", + "Scenario": "Сценарио" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index a66d5cbb..6576f470 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", "Timetable": "Распоред", "Wishes": "Жеље", - "Timetable wishes": "Жеље за распоред" + "Timetable wishes": "Жеље за распоред", + "Generator run": "Покретање генератора", + "Generator progress": "Напредак генератора", + "The run failed: {reason}": "Покретање није успело: {reason}", + "{placed} of {lessons} lessons placed": "Смештено {placed} од {lessons} часова", + "Generate again": "Генериши поново", + "Generate": "Генериши", + "Every lesson has a place.": "Сваки час има место.", + "Lesson": "Час", + "No free period and room": "Нема слободног часа и учионице", + "Broken soft wishes": "Прекршене флексибилне жеље", + "Every soft wish is kept.": "Свака флексибилна жеља је поштована.", + "Wish": "Жеља", + "Lessons": "Часови", + "Generating: {percent}% of the time budget used": "Генерисање: искоришћено {percent}% времена", + "Waiting to start": "Чека почетак", + "Published as draft lessons": "Објављено као нацрти часова", + "Could not read the scenario.": "Сценарио није могуће прочитати.", + "Could not start the run.": "Покретање није могуће започети.", + "Scenarios": "Сценарији", + "Timetable scenarios": "Сценарији распореда", + "Scenario": "Сценарио" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 4c4d112d..95b6971a 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", "Timetable": "Schema", "Wishes": "Önskemål", - "Timetable wishes": "Schemaönskemål" + "Timetable wishes": "Schemaönskemål", + "Generator run": "Generatorkörning", + "Generator progress": "Generatorns förlopp", + "The run failed: {reason}": "Körningen misslyckades: {reason}", + "{placed} of {lessons} lessons placed": "{placed} av {lessons} lektioner placerade", + "Generate again": "Generera igen", + "Generate": "Generera", + "Every lesson has a place.": "Varje lektion har en plats.", + "Lesson": "Lektion", + "No free period and room": "Ingen ledig lektion och sal", + "Broken soft wishes": "Brutna mjuka önskemål", + "Every soft wish is kept.": "Varje mjukt önskemål hålls.", + "Wish": "Önskemål", + "Lessons": "Lektioner", + "Generating: {percent}% of the time budget used": "Genererar: {percent} % av tiden använd", + "Waiting to start": "Väntar på start", + "Published as draft lessons": "Publicerat som utkastlektioner", + "Could not read the scenario.": "Scenariot kunde inte läsas.", + "Could not start the run.": "Körningen kunde inte startas.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Schemascenarier", + "Scenario": "Scenario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index 80ca608a..42d4a236 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", "Timetable": "Schema", "Wishes": "Önskemål", - "Timetable wishes": "Schemaönskemål" + "Timetable wishes": "Schemaönskemål", + "Generator run": "Generatorkörning", + "Generator progress": "Generatorns förlopp", + "The run failed: {reason}": "Körningen misslyckades: {reason}", + "{placed} of {lessons} lessons placed": "{placed} av {lessons} lektioner placerade", + "Generate again": "Generera igen", + "Generate": "Generera", + "Every lesson has a place.": "Varje lektion har en plats.", + "Lesson": "Lektion", + "No free period and room": "Ingen ledig lektion och sal", + "Broken soft wishes": "Brutna mjuka önskemål", + "Every soft wish is kept.": "Varje mjukt önskemål hålls.", + "Wish": "Önskemål", + "Lessons": "Lektioner", + "Generating: {percent}% of the time budget used": "Genererar: {percent} % av tiden använd", + "Waiting to start": "Väntar på start", + "Published as draft lessons": "Publicerat som utkastlektioner", + "Could not read the scenario.": "Scenariot kunde inte läsas.", + "Could not start the run.": "Körningen kunde inte startas.", + "Scenarios": "Scenarier", + "Timetable scenarios": "Schemascenarier", + "Scenario": "Scenario" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 552719b9..e7fb2cd1 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", "Timetable": "Ders programı", "Wishes": "Dilekler", - "Timetable wishes": "Ders programı dilekleri" + "Timetable wishes": "Ders programı dilekleri", + "Generator run": "Oluşturucu çalışması", + "Generator progress": "Oluşturucunun ilerlemesi", + "The run failed: {reason}": "Çalışma başarısız oldu: {reason}", + "{placed} of {lessons} lessons placed": "{lessons} dersten {placed} tanesi yerleştirildi", + "Generate again": "Yeniden oluştur", + "Generate": "Oluştur", + "Every lesson has a place.": "Her dersin bir yeri var.", + "Lesson": "Ders", + "No free period and room": "Boş ders saati ve derslik yok", + "Broken soft wishes": "Uyulmayan esnek dilekler", + "Every soft wish is kept.": "Her esnek dileğe uyuluyor.", + "Wish": "Dilek", + "Lessons": "Dersler", + "Generating: {percent}% of the time budget used": "Oluşturuluyor: sürenin %{percent} kadarı kullanıldı", + "Waiting to start": "Başlamayı bekliyor", + "Published as draft lessons": "Taslak dersler olarak yayımlandı", + "Could not read the scenario.": "Senaryo okunamadı.", + "Could not start the run.": "Çalışma başlatılamadı.", + "Scenarios": "Senaryolar", + "Timetable scenarios": "Ders programı senaryoları", + "Scenario": "Senaryo" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 2f55dbf2..0f3382b1 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", "Timetable": "Ders programı", "Wishes": "Dilekler", - "Timetable wishes": "Ders programı dilekleri" + "Timetable wishes": "Ders programı dilekleri", + "Generator run": "Oluşturucu çalışması", + "Generator progress": "Oluşturucunun ilerlemesi", + "The run failed: {reason}": "Çalışma başarısız oldu: {reason}", + "{placed} of {lessons} lessons placed": "{lessons} dersten {placed} tanesi yerleştirildi", + "Generate again": "Yeniden oluştur", + "Generate": "Oluştur", + "Every lesson has a place.": "Her dersin bir yeri var.", + "Lesson": "Ders", + "No free period and room": "Boş ders saati ve derslik yok", + "Broken soft wishes": "Uyulmayan esnek dilekler", + "Every soft wish is kept.": "Her esnek dileğe uyuluyor.", + "Wish": "Dilek", + "Lessons": "Dersler", + "Generating: {percent}% of the time budget used": "Oluşturuluyor: sürenin %{percent} kadarı kullanıldı", + "Waiting to start": "Başlamayı bekliyor", + "Published as draft lessons": "Taslak dersler olarak yayımlandı", + "Could not read the scenario.": "Senaryo okunamadı.", + "Could not start the run.": "Çalışma başlatılamadı.", + "Scenarios": "Senaryolar", + "Timetable scenarios": "Ders programı senaryoları", + "Scenario": "Senaryo" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index f930a322..ce906efb 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1024,7 +1024,28 @@ OC.L10N.register( "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", "Timetable": "Розклад", "Wishes": "Побажання", - "Timetable wishes": "Побажання до розкладу" + "Timetable wishes": "Побажання до розкладу", + "Generator run": "Запуск генератора", + "Generator progress": "Хід генератора", + "The run failed: {reason}": "Запуск не вдався: {reason}", + "{placed} of {lessons} lessons placed": "Розміщено {placed} з {lessons} уроків", + "Generate again": "Згенерувати знову", + "Generate": "Згенерувати", + "Every lesson has a place.": "Кожен урок має місце.", + "Lesson": "Урок", + "No free period and room": "Немає вільного уроку й кабінету", + "Broken soft wishes": "Порушені гнучкі побажання", + "Every soft wish is kept.": "Кожне гнучке побажання дотримано.", + "Wish": "Побажання", + "Lessons": "Уроки", + "Generating: {percent}% of the time budget used": "Генерування: використано {percent}% часу", + "Waiting to start": "Очікує запуску", + "Published as draft lessons": "Опубліковано як чернетки уроків", + "Could not read the scenario.": "Не вдалося прочитати сценарій.", + "Could not start the run.": "Не вдалося почати запуск.", + "Scenarios": "Сценарії", + "Timetable scenarios": "Сценарії розкладу", + "Scenario": "Сценарій" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 713e5c03..99922297 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1023,7 +1023,28 @@ "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", "Timetable": "Розклад", "Wishes": "Побажання", - "Timetable wishes": "Побажання до розкладу" + "Timetable wishes": "Побажання до розкладу", + "Generator run": "Запуск генератора", + "Generator progress": "Хід генератора", + "The run failed: {reason}": "Запуск не вдався: {reason}", + "{placed} of {lessons} lessons placed": "Розміщено {placed} з {lessons} уроків", + "Generate again": "Згенерувати знову", + "Generate": "Згенерувати", + "Every lesson has a place.": "Кожен урок має місце.", + "Lesson": "Урок", + "No free period and room": "Немає вільного уроку й кабінету", + "Broken soft wishes": "Порушені гнучкі побажання", + "Every soft wish is kept.": "Кожне гнучке побажання дотримано.", + "Wish": "Побажання", + "Lessons": "Уроки", + "Generating: {percent}% of the time budget used": "Генерування: використано {percent}% часу", + "Waiting to start": "Очікує запуску", + "Published as draft lessons": "Опубліковано як чернетки уроків", + "Could not read the scenario.": "Не вдалося прочитати сценарій.", + "Could not start the run.": "Не вдалося почати запуск.", + "Scenarios": "Сценарії", + "Timetable scenarios": "Сценарії розкладу", + "Scenario": "Сценарій" }, "plurals": {} } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a4f835d4..d3f9bc54 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -33,6 +33,8 @@ use OCA\Planninq\Listener\TimetableSessionsQueryListener; use OCA\Planninq\Listener\TimetableUpsertRequestedListener; use OCA\Planninq\Settings\AdminSettings; +use OCA\Planninq\Timetabling\LocalSearchSolver; +use OCA\Planninq\Timetabling\TimetableSolver; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -98,6 +100,10 @@ public function register(IRegistrationContext $context): void { // import, the kanban Project/Dependency/Label controllers) are kept. $this->registerAppHost(context: $context); + // The timetable generator's engine (timetabling-generator design decision 1, DECISIONS row 21): + // the PHP local search now; a CP-SAT sidecar can be bound here per instance later. + $context->registerServiceAlias(TimetableSolver::class, LocalSearchSolver::class); + // Publish the projects leaf on OpenRegister's integration registry, so // sibling apps render planninq's projects instead of querying for them. $this->registerProjectsLeaf(context: $context); diff --git a/lib/BackgroundJob/GenerateTimetableScenario.php b/lib/BackgroundJob/GenerateTimetableScenario.php new file mode 100644 index 00000000..e38c25df --- /dev/null +++ b/lib/BackgroundJob/GenerateTimetableScenario.php @@ -0,0 +1,90 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\BackgroundJob; + +use OCA\Planninq\Service\TimetableGenerationService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; + +/** + * Runs one step of a timetable scenario run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class GenerateTimetableScenario extends QueuedJob { + + /** + * Constructor. + * + * @param ITimeFactory $time The clock. + * @param TimetableGenerationService $generation Runs a step. + * @param IJobList $jobList Queues the next step. + * + * @return void + */ + public function __construct( + ITimeFactory $time, + private readonly TimetableGenerationService $generation, + private readonly IJobList $jobList, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Run the step. + * + * @param mixed $argument `['scenario' => id]`. + * + * @return void + */ + protected function run($argument): void { + $this->runStep(argument: $argument); + }//end run() + + /** + * Run one step and queue the next one when the run should go on; whether it was queued. + * + * @param mixed $argument `['scenario' => id]`. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function runStep(mixed $argument): bool { + $id = ''; + if (is_array($argument) === true) { + $id = (string)($argument['scenario'] ?? ''); + } + + if ($id === '' || $this->generation->step(id: $id) === false) { + return false; + } + + $this->jobList->add(self::class, ['scenario' => $id]); + return true; + }//end runStep() +}//end class diff --git a/lib/Controller/TimetableScenarioController.php b/lib/Controller/TimetableScenarioController.php new file mode 100644 index 00000000..90ec8748 --- /dev/null +++ b/lib/Controller/TimetableScenarioController.php @@ -0,0 +1,91 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Controller; + +use InvalidArgumentException; +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableGenerationService; +use OCA\Planninq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; + +/** + * The generate endpoint of timetable scenarios. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param TimetableGenerationService $generation Queues the run. + * @param IJobList $jobList Runs it in the background. + * @param SettingsService $settingsService Tells whether the user is an admin. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly TimetableGenerationService $generation, + private readonly IJobList $jobList, + private readonly SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Queue a generator run for a scenario; the page follows its progress on the stored scenario. + * + * @param string $id The scenario id. + * + * @return JSONResponse + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function generate(string $id): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can generate a timetable.'], Http::STATUS_FORBIDDEN); + } + + try { + $scenario = $this->generation->queue(id: $id); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + $this->jobList->add(GenerateTimetableScenario::class, ['scenario' => $id]); + + return new JSONResponse(['id' => $id, 'status' => $scenario['status'], 'lessons' => count($scenario['input']['lessons'])], Http::STATUS_ACCEPTED); + }//end generate() +}//end class diff --git a/lib/Service/TimetableGenerationService.php b/lib/Service/TimetableGenerationService.php new file mode 100644 index 00000000..e8bff589 --- /dev/null +++ b/lib/Service/TimetableGenerationService.php @@ -0,0 +1,230 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use InvalidArgumentException; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\TimetableSolver; +use Throwable; + +/** + * Queue and step a generated timetable scenario. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableGenerationService { + + /** + * The longest one step runs, in seconds. + */ + public const STEP_SECONDS = 60; + + /** + * Statuses a run may be queued from. + */ + private const QUEUEABLE = ['queued', 'done', 'failed']; + + /** + * Constructor. + * + * @param TimetableScenarioStore $store Reads and writes scenarios and wishes. + * @param TimetableInputBuilder $inputBuilder Builds the input from learniq or the uploaded sheets. + * @param TimetableGridService $grid Holds the time budget. + * @param TimetableSolver $solver Places the lessons. + * + * @return void + */ + public function __construct( + private readonly TimetableScenarioStore $store, + private readonly TimetableInputBuilder $inputBuilder, + private readonly TimetableGridService $grid, + private readonly TimetableSolver $solver, + ) { + }//end __construct() + + /** + * Make a scenario ready for a run: snapshot the input, pick a seed, clear the last run, status queued. + * + * @param string $id The scenario id. + * + * @return array The stored scenario. + * + * @throws InvalidArgumentException When the scenario does not exist, is imported or published, or has no activities. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function queue(string $id): array { + $scenario = $this->store->load(id: $id); + if ($scenario === null) { + throw new InvalidArgumentException('There is no timetable scenario with this id.'); + } + + if (($scenario['source'] ?? '') !== 'generated' || in_array(($scenario['status'] ?? 'queued'), self::QUEUEABLE, true) === false) { + throw new InvalidArgumentException('Only a generated scenario that is not running or published can be generated.'); + } + + $input = $this->inputBuilder->build( + academicYear: $this->academicYear(date: (string)($scenario['windowFrom'] ?? '')), + wishes: $this->store->wishes() + ); + if ($input->isEmpty() === true) { + throw new InvalidArgumentException((string)$input->reason); + } + + $scenario = array_merge( + $scenario, + [ + 'status' => 'queued', + 'seed' => ($scenario['seed'] ?? random_int(1, 2147483646)), + 'input' => $input->toArray(), + 'placements' => [], + 'unplaced' => [], + 'brokenWishes' => [], + 'metrics' => ['secondsSpent' => 0, 'steps' => 0, 'budgetSeconds' => $this->budgetSeconds()], + 'reason' => '', + ] + ); + $this->store->save(id: $id, data: $scenario); + + return $scenario; + }//end queue() + + /** + * Run one step and store the result; whether the job should run another step. + * + * @param string $id The scenario id. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function step(string $id): bool { + $scenario = $this->store->load(id: $id); + if ($scenario === null || in_array(($scenario['status'] ?? ''), ['queued', 'running'], true) === false) { + return false; + } + + try { + return $this->runStep(id: $id, scenario: $scenario); + } catch (Throwable $e) { + $this->store->save(id: $id, data: array_merge($scenario, ['status' => 'failed', 'reason' => $e->getMessage()])); + return false; + } + }//end step() + + /** + * One step: solve from the best placements so far for at most a minute of the budget left. + * + * @param string $id The scenario id. + * @param array $scenario The stored scenario. + * + * @return bool Whether another step should run. + */ + private function runStep(string $id, array $scenario): bool { + $metrics = (array)($scenario['metrics'] ?? []); + $budget = (int)($metrics['budgetSeconds'] ?? $this->budgetSeconds()); + $spent = (int)($metrics['secondsSpent'] ?? 0); + $input = $this->inputOf(scenario: $scenario); + $seconds = min(self::STEP_SECONDS, max(1, ($budget - $spent))); + $began = microtime(true); + $result = $this->solver->solve( + input: $input, + seconds: $seconds, + seed: ((int)($scenario['seed'] ?? 1) + (int)($metrics['steps'] ?? 0)), + start: (array)($scenario['placements'] ?? []) + ); + + $spent += max(1, (int)ceil(microtime(true) - $began)); + $previous = ($metrics['cost'] ?? null); + $improved = ($previous === null || $result->cost < (int)$previous); + $more = ($improved === true && $spent < $budget && $result->cost > 0); + + $this->store->save( + id: $id, + data: array_merge( + $scenario, + $result->toScenario(), + [ + 'status' => ($more === true ? 'running' : 'done'), + 'metrics' => array_merge( + $result->metrics, + ['cost' => $result->cost, 'secondsSpent' => $spent, 'steps' => ((int)($metrics['steps'] ?? 0) + 1), 'budgetSeconds' => $budget] + ), + ] + ) + ); + + return $more; + }//end runStep() + + /** + * The solver input a scenario keeps. + * + * @param array $scenario The scenario. + * + * @return SolverInput + * + * @throws InvalidArgumentException When the scenario has no lessons. + */ + private function inputOf(array $scenario): SolverInput { + $input = (array)($scenario['input'] ?? []); + if (($input['lessons'] ?? []) === []) { + throw new InvalidArgumentException('The scenario has no lessons to place.'); + } + + return new SolverInput( + periods: (array)($input['periods'] ?? []), + rooms: (array)($input['rooms'] ?? []), + lessons: (array)$input['lessons'], + wishes: (array)($input['wishes'] ?? []), + source: (string)($input['source'] ?? 'none') + ); + }//end inputOf() + + /** + * The time budget of a run in seconds. + * + * @return int + */ + private function budgetSeconds(): int { + return ((int)($this->grid->settings()[TimetableGridService::BUDGET_KEY] ?? TimetableGridService::DEFAULT_BUDGET) * 60); + }//end budgetSeconds() + + /** + * The academic year a date falls in: from August on it is this year and the next. + * + * @param string $date A date, as in `2026-10-05`. + * + * @return string As in `2026-2027`. + */ + private function academicYear(string $date): string { + $time = (strtotime($date) ?: time()); + $year = (int)date('Y', $time); + $start = ((int)date('n', $time) >= 8) ? $year : ($year - 1); + return $start.'-'.($start + 1); + }//end academicYear() +}//end class diff --git a/lib/Service/TimetableScenarioStore.php b/lib/Service/TimetableScenarioStore.php new file mode 100644 index 00000000..260587f1 --- /dev/null +++ b/lib/Service/TimetableScenarioStore.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use Psr\Container\ContainerInterface; +use RuntimeException; +use Throwable; + +/** + * The generator's access to its scenarios and wishes. + * + * Written with RBAC off: the store is reached only from the admin-only + * generate endpoint and from the queued job, which runs without a user. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioStore { + + /** + * The register slug. + */ + public const REGISTER = 'planninq'; + + /** + * The scenario schema slug. + */ + public const SCENARIO = 'timetableScenario'; + + /** + * The wish schema slug. + */ + public const WISH = 'timetableWish'; + + /** + * The most wishes one run reads. + */ + private const MAX_WISHES = 1000; + + /** + * OpenRegister ObjectService FQCN. + */ + private const OR_OBJECT_SERVICE = 'OCA\\OpenRegister\\Service\\ObjectService'; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister's ObjectService by name. + * + * @return void + */ + public function __construct( + private readonly ContainerInterface $container, + ) { + }//end __construct() + + /** + * One scenario's data, or null when it does not exist. + * + * @param string $id The scenario id. + * + * @return array|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function load(string $id): ?array { + try { + $object = $this->objectService()->find(id: $id, register: self::REGISTER, schema: self::SCENARIO, _rbac: false); + } catch (Throwable) { + return null; + } + + return $this->dataOf(object: $object); + }//end load() + + /** + * Store a scenario's data under its id. + * + * @param string $id The scenario id. + * @param array $data The complete scenario. + * + * @return void + * + * @throws RuntimeException When OpenRegister refuses it. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function save(string $id, array $data): void { + unset($data['@self'], $data['id']); + $saved = $this->objectService()->saveObject(object: $data, register: self::REGISTER, schema: self::SCENARIO, uuid: $id, _rbac: false); + if ($saved === null) { + throw new RuntimeException('OpenRegister did not store the scenario.'); + } + }//end save() + + /** + * Every wish. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function wishes(): array { + $results = $this->objectService()->searchObjectsBySlug( + registerSlug: self::REGISTER, + schemaSlug: self::WISH, + filters: ['_limit' => self::MAX_WISHES], + _rbac: false + ); + $wishes = []; + foreach ((new TimetableSessionRows())->listOf(results: $results) as $row) { + $data = $this->dataOf(object: $row); + if ($data !== null) { + $wishes[] = $data; + } + } + + return $wishes; + }//end wishes() + + /** + * An object's data with its id, from an ObjectEntity or an array; null for anything else. + * + * @param mixed $object The object. + * + * @return array|null + */ + private function dataOf(mixed $object): ?array { + if (is_object($object) === false && is_array($object) === false) { + return null; + } + + $rows = new TimetableSessionRows(); + $data = $rows->dataOf(row: $object); + $id = $rows->idOf(row: $object); + if ($id !== '') { + $data['id'] = $id; + } + + return $data; + }//end dataOf() + + /** + * OpenRegister's ObjectService, cleared of any earlier caller's scope. + * + * @return object + * + * @throws RuntimeException When OpenRegister is not available. + */ + private function objectService(): object { + try { + $objectService = $this->container->get(self::OR_OBJECT_SERVICE); + } catch (Throwable $e) { + throw new RuntimeException('OpenRegister is not available.', 0, $e); + } + + if (method_exists($objectService, 'clearCurrents') === true) { + $objectService->clearCurrents(); + } + + return $objectService; + }//end objectService() +}//end class diff --git a/lib/Timetabling/LocalSearchSolver.php b/lib/Timetabling/LocalSearchSolver.php new file mode 100644 index 00000000..92556c83 --- /dev/null +++ b/lib/Timetabling/LocalSearchSolver.php @@ -0,0 +1,353 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + + +/** + * Greedy construction, simulated annealing, and a repair pass for hard wishes. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +final class LocalSearchSolver implements TimetableSolver { + + /** + * Moves tried in one call; the job's 60-second steps repeat calls from the best state. + */ + public const STEP_ITERATIONS = 4000; + + /** + * The starting temperature, in cost units: a few soft wishes. + */ + private const START_TEMPERATURE = 40.0; + + /** + * Constructor. + * + * @param TimetableScorer $scorer The cost the search minimises. + * @param PlacementOptions $options The options per lesson. + * @param WishChecker $wishes Evaluates the wishes. + * @param int $iterations Moves per call. + * + * @return void + */ + public function __construct( + private readonly TimetableScorer $scorer=new TimetableScorer(), + private readonly PlacementOptions $options=new PlacementOptions(), + private readonly WishChecker $wishes=new WishChecker(), + private readonly int $iterations=self::STEP_ITERATIONS, + ) { + }//end __construct() + + /** + * Place the lessons. + * + * @param SolverInput $input What to place. + * @param int $seconds The most time this call may take. + * @param int $seed The seed. + * @param array $start Placements to continue from. + * + * @return SolverResult + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function solve(SolverInput $input, int $seconds, int $seed, array $start=[]): SolverResult { + $deadline = (microtime(true) + max(1, $seconds)); + $lessons = array_column($input->lessons, null, 'key'); + $built = $this->options->build(input: $input); + $state = new SearchState(lessons: $lessons); + $random = new SeededRandom(seed: $seed); + + $this->resume(state: $state, options: $built['options'], start: $start); + $this->construct(input: $input, state: $state, options: $built['options']); + [$best, $iterations] = $this->anneal(input: $input, state: $state, options: $built['options'], random: $random, deadline: $deadline); + + $state = new SearchState(lessons: $lessons); + $this->resume(state: $state, options: $built['options'], start: $best); + $repaired = $this->repair(input: $input, state: $state); + + return $this->result( + input: $input, + state: $state, + blockers: $built['blockers'], + repaired: $repaired, + iterations: $iterations + ); + }//end solve() + + /** + * Put back the placements of an earlier step that are still options and free. + * + * @param SearchState $state The state. + * @param array>> $options The options per lesson. + * @param array $start The earlier placements. + * + * @return void + */ + private function resume(SearchState $state, array $options, array $start): void { + foreach ($start as $placement) { + $key = (string)($placement['lesson'] ?? ''); + foreach (($options[$key] ?? []) as $option) { + if ($option['period'] === (string)$placement['period'] && $option['room'] === (string)$placement['room']) { + $state->place(key: $key, option: $option); + break; + } + } + } + }//end resume() + + /** + * Place every lesson not yet placed, the one with the fewest options first, on its cheapest free option. + * + * @param SolverInput $input The input. + * @param SearchState $state The state. + * @param array>> $options The options per lesson. + * + * @return void + */ + private function construct(SolverInput $input, SearchState $state, array $options): void { + $order = array_keys($options); + usort($order, static fn (string $one, string $two): int => [count($options[$one]), $one] <=> [count($options[$two]), $two]); + $lessons = array_column($input->lessons, null, 'key'); + $soft = array_values(array_filter($input->wishes, static fn (array $wish): bool => ($wish['strength'] ?? 'soft') !== 'hard')); + foreach ($order as $key) { + if ($state->option(key: (string)$key) !== null) { + continue; + } + + $choice = null; + $lowest = PHP_INT_MAX; + foreach ($options[$key] as $option) { + $price = $this->price(lesson: $lessons[$key], option: $option, soft: $soft, state: $state); + if ($price < $lowest && $state->fits(key: (string)$key, option: $option) === true) { + $choice = $option; + $lowest = $price; + } + } + + if ($choice !== null) { + $state->place(key: (string)$key, option: $choice); + } + }//end foreach + }//end construct() + + /** + * The local price of an option while constructing: the soft wishes it breaks by itself, + * then how full the day already is for the teacher and the group, so lessons spread over the week. + * + * @param array $lesson The lesson. + * @param array $option The option. + * @param array> $soft The soft wishes. + * @param SearchState $state The state. + * + * @return int + */ + private function price(array $lesson, array $option, array $soft, SearchState $state): int { + $price = 0; + foreach ($soft as $wish) { + if ($this->wishes->forbids(wish: $wish, lesson: $lesson, periods: $option['periods'], room: $option['room']) === true) { + $price += (max(1, (int)($wish['weight'] ?? 1)) * TimetableScorer::SOFT_COST * 10); + } + } + + $day = explode('-', (string)$option['period'], 2)[0]; + return $price + + $state->onDay(field: 'group', reference: (string)($lesson['group'] ?? ''), day: $day) + + $state->onDay(field: 'teacher', reference: (string)($lesson['teacher'] ?? ''), day: $day); + }//end price() + + /** + * Simulated annealing: move one lesson to another free option, keep the move when it + * costs less or, with a chance that shrinks as the search cools, when it costs a little more. + * + * @param SolverInput $input The input. + * @param SearchState $state The state. + * @param array>> $options The options per lesson. + * @param SeededRandom $random The random numbers. + * @param float $deadline When to stop, as microtime. + * + * @return array{0:array>,1:int} The best placements and the moves tried. + */ + private function anneal(SolverInput $input, SearchState $state, array $options, SeededRandom $random, float $deadline): array { + $movable = array_values(array_filter(array_keys($options), static fn ($key): bool => $options[$key] !== [])); + $cost = $this->cost(input: $input, state: $state); + $best = [$cost, $state->placements()]; + $tried = 0; + while ($movable !== [] && $tried < $this->iterations && microtime(true) < $deadline) { + $tried++; + $key = (string)$movable[$random->below(max: count($movable))]; + $target = $options[$key][$random->below(max: count($options[$key]))]; + $before = $state->remove(key: $key); + if ($state->place(key: $key, option: $target) === false) { + $this->restore(state: $state, key: $key, option: $before); + continue; + } + + $next = $this->cost(input: $input, state: $state); + if ($this->accept(delta: ($next - $cost), progress: ($tried / $this->iterations), random: $random) === false) { + $state->remove(key: $key); + $this->restore(state: $state, key: $key, option: $before); + continue; + } + + $cost = $next; + if ($cost < $best[0]) { + $best = [$cost, $state->placements()]; + } + }//end while + + return [$best[1], $tried]; + }//end anneal() + + /** + * Whether to keep a move. + * + * @param int $delta The change in cost. + * @param float $progress How far the search is, 0 to 1. + * @param SeededRandom $random The random numbers. + * + * @return bool + */ + private function accept(int $delta, float $progress, SeededRandom $random): bool { + if ($delta <= 0) { + return true; + } + + $temperature = max(0.01, (self::START_TEMPERATURE * (1.0 - $progress))); + return $random->fraction() < exp(-$delta / $temperature); + }//end accept() + + /** + * Put a lesson back on the option it had, if it had one. + * + * @param SearchState $state The state. + * @param string $key The lesson key. + * @param array|null $option The option it had. + * + * @return void + */ + private function restore(SearchState $state, string $key, ?array $option): void { + if ($option !== null) { + $state->place(key: $key, option: $option); + } + }//end restore() + + /** + * The scorer's cost of the current placements, with every lesson that breaks a hard wish + * counted as a hard breach, so the search also shrinks how badly a hard wish is broken. + * + * @param SolverInput $input The input. + * @param SearchState $state The state. + * + * @return int + */ + private function cost(SolverInput $input, SearchState $state): int { + $score = $this->scorer->score(input: $input, placements: $state->placements()); + $cost = TimetableScorer::cost($score['metrics']); + foreach ($score['brokenWishes'] as $broken) { + if ($broken['strength'] === 'hard') { + $cost += (count($broken['lessons']) * TimetableScorer::HARD_COST); + } + } + + return $cost; + }//end cost() + + /** + * Take off the grid every lesson that still breaks a hard wish, so a generated + * scenario never breaks one; returns the wish id per lesson taken off. + * + * @param SolverInput $input The input. + * @param SearchState $state The state. + * + * @return array + */ + private function repair(SolverInput $input, SearchState $state): array { + $taken = []; + foreach ($input->wishes as $wish) { + if (($wish['strength'] ?? '') !== 'hard') { + continue; + } + + $broken = $this->scorer->score(input: $input, placements: $state->placements())['brokenWishes']; + foreach ($broken as $entry) { + if ($entry['wish'] !== (string)($wish['id'] ?? '')) { + continue; + } + + foreach ($entry['lessons'] as $key) { + $state->remove(key: (string)$key); + $taken[(string)$key] = (string)$entry['wish']; + } + } + } + + return $taken; + }//end repair() + + /** + * The result: placements, unplaced lessons with the wish that blocked them, and the score. + * + * @param SolverInput $input The input. + * @param SearchState $state The final state. + * @param array> $blockers The hard wishes that narrowed each lesson's options. + * @param array $repaired The lessons taken off by the repair, with the wish. + * @param int $iterations The moves tried. + * + * @return SolverResult + */ + private function result(SolverInput $input, SearchState $state, array $blockers, array $repaired, int $iterations): SolverResult { + $unplaced = []; + foreach ($input->lessons as $lesson) { + $key = (string)$lesson['key']; + if ($state->option(key: $key) !== null) { + continue; + } + + $wish = ($repaired[$key] ?? ($blockers[$key][0] ?? null)); + $unplaced[] = [ + 'lesson' => $key, + 'wish' => $wish, + 'reason' => match (true) { + isset($repaired[$key]) => 'hardWish', + $wish !== null => 'hardWishPeriods', + default => 'noFreePlace', + }, + ]; + } + + $placements = $state->placements(); + $score = $this->scorer->score(input: $input, placements: $placements); + return new SolverResult( + placements: $placements, + unplaced: $unplaced, + brokenWishes: $score['brokenWishes'], + metrics: $score['metrics'], + cost: TimetableScorer::cost($score['metrics']), + iterations: $iterations + ); + }//end result() +}//end class diff --git a/lib/Timetabling/PlacementOptions.php b/lib/Timetabling/PlacementOptions.php new file mode 100644 index 00000000..ae22a401 --- /dev/null +++ b/lib/Timetabling/PlacementOptions.php @@ -0,0 +1,148 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The options of every lesson, and the hard wishes that narrowed them. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +final class PlacementOptions { + + /** + * Constructor. + * + * @param WishChecker $wishes Evaluates the wishes. + * + * @return void + */ + public function __construct( + private readonly WishChecker $wishes=new WishChecker(), + ) { + }//end __construct() + + /** + * The options per lesson key, and per lesson key the ids of the hard wishes that removed options. + * + * @param SolverInput $input The input. + * + * @return array{options:array}>>,blockers:array>} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function build(SolverInput $input): array { + $grid = new WeekGrid(periods: $input->periods); + $hard = array_values(array_filter($input->wishes, static fn (array $wish): bool => ($wish['strength'] ?? '') === 'hard')); + $shared = []; + $options = []; + $blockers = []; + foreach ($input->lessons as $lesson) { + $key = (string)$lesson['key']; + $signature = (string)($lesson['roomType'] ?? '')."\n".(int)($lesson['length'] ?? 1); + $shared[$signature] ??= $this->allOptions(input: $input, grid: $grid, lesson: $lesson); + $options[$key] = []; + $blockers[$key] = []; + foreach ($shared[$signature] as $option) { + $blocking = $this->blocking(wishes: $hard, lesson: $lesson, periods: $option['periods'], room: $option['room']); + if ($blocking !== null) { + $blockers[$key][$blocking] = $blocking; + continue; + } + + $options[$key][] = $option; + } + + $blockers[$key] = array_values($blockers[$key]); + } + + return ['options' => $options, 'blockers' => $blockers]; + }//end build() + + /** + * Every start period and room of the right type a lesson of this length fits in; lessons + * with the same room type and length share this list, so a large school stays small in memory. + * + * @param SolverInput $input The input. + * @param WeekGrid $grid The week grid. + * @param array $lesson A lesson of the signature. + * + * @return array}> + */ + private function allOptions(SolverInput $input, WeekGrid $grid, array $lesson): array { + $options = []; + foreach ($this->rooms(input: $input, lesson: $lesson) as $room) { + foreach ($grid->keys() as $start) { + $periods = $grid->span(start: $start, length: (int)($lesson['length'] ?? 1)); + if ($periods !== null) { + $options[] = ['period' => $start, 'room' => $room, 'periods' => $periods]; + } + } + } + + return $options; + }//end allOptions() + + /** + * The rooms of the type the lesson needs; every room when it needs none. + * + * @param SolverInput $input The input. + * @param array $lesson The lesson. + * + * @return array + */ + private function rooms(SolverInput $input, array $lesson): array { + $needed = (string)($lesson['roomType'] ?? ''); + $rooms = []; + foreach ($input->rooms as $room) { + if ($needed === '' || (string)($room['type'] ?? '') === $needed) { + $rooms[] = (string)$room['reference']; + } + } + + return $rooms; + }//end rooms() + + /** + * The id of the first hard wish that forbids this option, or null. + * + * @param array> $wishes The hard wishes. + * @param array $lesson The lesson. + * @param array $periods The periods it would take. + * @param string $room The room. + * + * @return string|null + */ + private function blocking(array $wishes, array $lesson, array $periods, string $room): ?string { + foreach ($wishes as $wish) { + if ($this->wishes->forbids(wish: $wish, lesson: $lesson, periods: $periods, room: $room) === true) { + return (string)($wish['id'] ?? ''); + } + } + + return null; + }//end blocking() +}//end class diff --git a/lib/Timetabling/SearchState.php b/lib/Timetabling/SearchState.php new file mode 100644 index 00000000..58024dd8 --- /dev/null +++ b/lib/Timetabling/SearchState.php @@ -0,0 +1,226 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + + +/** + * The placements of a search and the periods they keep busy. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +final class SearchState { + + /** + * The option each placed lesson has, by lesson key. + * + * @var array}> + */ + private array $placed = []; + + /** + * Busy teacher, group and room periods, by "kind, reference, period". + * + * @var array + */ + private array $busy = []; + + /** + * Lessons per teacher or group and day, by "field, reference, day". + * + * @var array + */ + private array $days = []; + + /** + * Constructor. + * + * @param array> $lessons The lessons by key. + * + * @return void + */ + public function __construct(private readonly array $lessons) { + }//end __construct() + + /** + * Place a lesson when the option is free for its teacher, group and room. + * + * @param string $key The lesson key. + * @param array{period:string,room:string,periods:array} $option The option. + * + * @return bool Whether it was placed. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function place(string $key, array $option): bool { + if ($this->fits(key: $key, option: $option) === false) { + return false; + } + + foreach ($this->marks(key: $key, option: $option) as $mark) { + $this->busy[$mark] = $key; + } + + $this->placed[$key] = $option; + foreach ($this->dayMarks(key: $key, option: $option) as $mark) { + $this->days[$mark] = (($this->days[$mark] ?? 0) + 1); + } + + return true; + }//end place() + + /** + * Whether the option is free for the lesson's teacher, group and room. + * + * @param string $key The lesson key. + * @param array{period:string,room:string,periods:array} $option The option. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function fits(string $key, array $option): bool { + foreach ($this->marks(key: $key, option: $option) as $mark) { + if (isset($this->busy[$mark]) === true) { + return false; + } + } + + return true; + }//end fits() + + /** + * Take a lesson off the grid. + * + * @param string $key The lesson key. + * + * @return array{period:string,room:string,periods:array}|null The option it had. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function remove(string $key): ?array { + $option = ($this->placed[$key] ?? null); + if ($option === null) { + return null; + } + + foreach ($this->marks(key: $key, option: $option) as $mark) { + unset($this->busy[$mark]); + } + + foreach ($this->dayMarks(key: $key, option: $option) as $mark) { + $this->days[$mark]--; + } + + unset($this->placed[$key]); + return $option; + }//end remove() + + /** + * The option a lesson has, or null. + * + * @param string $key The lesson key. + * + * @return array{period:string,room:string,periods:array}|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function option(string $key): ?array { + return ($this->placed[$key] ?? null); + }//end option() + + /** + * How many lessons a teacher or group already has on the day of a period. + * + * @param string $field `teacher` or `group`. + * @param string $reference The teacher or group. + * @param string $day The day, as in `mon`. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function onDay(string $field, string $reference, string $day): int { + return ($this->days[$field."\n".$reference."\n".$day] ?? 0); + }//end onDay() + + /** + * The placements as the scorer and the scenario read them, in lesson order. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function placements(): array { + $list = []; + foreach (array_keys($this->lessons) as $key) { + $option = ($this->placed[$key] ?? null); + if ($option !== null) { + $list[] = ['lesson' => (string)$key, 'period' => $option['period'], 'room' => $option['room']]; + } + } + + return $list; + }//end placements() + + /** + * The teacher and group day counters a lesson on an option adds to. + * + * @param string $key The lesson key. + * @param array{period:string,room:string,periods:array} $option The option. + * + * @return array + */ + private function dayMarks(string $key, array $option): array { + $lesson = ($this->lessons[$key] ?? []); + $day = explode('-', $option['period'], 2)[0]; + return [ + 'teacher'."\n".(string)($lesson['teacher'] ?? '')."\n".$day, + 'group'."\n".(string)($lesson['group'] ?? '')."\n".$day, + ]; + }//end dayMarks() + + /** + * The busy marks a lesson on an option makes. + * + * @param string $key The lesson key. + * @param array{period:string,room:string,periods:array} $option The option. + * + * @return array + */ + private function marks(string $key, array $option): array { + $lesson = ($this->lessons[$key] ?? []); + $owners = ['teacher' => (string)($lesson['teacher'] ?? ''), 'group' => (string)($lesson['group'] ?? ''), 'room' => $option['room']]; + $marks = []; + foreach ($option['periods'] as $period) { + foreach ($owners as $kind => $reference) { + if ($reference !== '') { + $marks[] = $kind."\n".$reference."\n".$period; + } + } + } + + return $marks; + }//end marks() +}//end class diff --git a/lib/Timetabling/SeededRandom.php b/lib/Timetabling/SeededRandom.php new file mode 100644 index 00000000..0f6901b3 --- /dev/null +++ b/lib/Timetabling/SeededRandom.php @@ -0,0 +1,91 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Deterministic pseudo-random numbers from a seed. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +final class SeededRandom { + + /** + * The modulus, 2^31 - 1. + */ + private const MODULUS = 2147483647; + + /** + * The current state, 1 to MODULUS - 1. + * + * @var integer + */ + private int $state; + + /** + * Constructor. + * + * @param int $seed Any integer. + * + * @return void + */ + public function __construct(int $seed) { + $this->state = ((abs($seed) % (self::MODULUS - 1)) + 1); + }//end __construct() + + /** + * A whole number from 0 to below the maximum. + * + * @param int $max The number of choices, 1 or more. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function below(int $max): int { + return ($this->next() % max(1, $max)); + }//end below() + + /** + * A number from 0 to below 1. + * + * @return float + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function fraction(): float { + return (($this->next() - 1) / (self::MODULUS - 1)); + }//end fraction() + + /** + * The next state. + * + * @return int + */ + private function next(): int { + $this->state = (($this->state * 48271) % self::MODULUS); + return $this->state; + }//end next() +}//end class diff --git a/lib/Timetabling/SolverResult.php b/lib/Timetabling/SolverResult.php new file mode 100644 index 00000000..97475145 --- /dev/null +++ b/lib/Timetabling/SolverResult.php @@ -0,0 +1,72 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The outcome of one solver run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +final class SolverResult { + + /** + * Constructor. + * + * @param array $placements Each placed lesson with its start period and room. + * @param array $unplaced Each lesson without a place, with the hard wish that blocked it. + * @param array> $brokenWishes The wishes the placements break. + * @param array $metrics The scorer's measures. + * @param int $cost The one number the search minimised. + * @param int $iterations How many moves the search tried. + * + * @return void + */ + public function __construct( + public readonly array $placements, + public readonly array $unplaced, + public readonly array $brokenWishes, + public readonly array $metrics, + public readonly int $cost, + public readonly int $iterations, + ) { + }//end __construct() + + /** + * The fields a timetableScenario keeps of a run. + * + * @return array{placements:array>,unplaced:array>,brokenWishes:array>,metrics:array} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function toScenario(): array { + return [ + 'placements' => $this->placements, + 'unplaced' => $this->unplaced, + 'brokenWishes' => $this->brokenWishes, + 'metrics' => $this->metrics, + ]; + }//end toScenario() +}//end class diff --git a/lib/Timetabling/TimetableSolver.php b/lib/Timetabling/TimetableSolver.php new file mode 100644 index 00000000..dbae5afc --- /dev/null +++ b/lib/Timetabling/TimetableSolver.php @@ -0,0 +1,48 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Places the lessons of a solver input on the week grid. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +interface TimetableSolver { + + /** + * Place the lessons, for at most the given number of seconds. + * + * @param SolverInput $input What to place. + * @param int $seconds The most time this call may take. + * @param int $seed The number the search starts from; the same seed gives the same result. + * @param array $start Placements to continue from (the best of an earlier step). + * + * @return SolverResult + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function solve(SolverInput $input, int $seconds, int $seed, array $start=[]): SolverResult; +}//end interface diff --git a/openspec/changes/timetabling-generator/design.md b/openspec/changes/timetabling-generator/design.md index 73932ef4..f6e07d66 100644 --- a/openspec/changes/timetabling-generator/design.md +++ b/openspec/changes/timetabling-generator/design.md @@ -40,8 +40,15 @@ Three ways to solve it were weighed: | B. Sidecar with OR-Tools CP-SAT | A Python ExApp (AppAPI) that takes the JSON input and returns placements | Much better results on tight rosters; proves infeasibility | Needs AppAPI and a deploy daemon on the instance; one more container to build, release and keep secure; not available on every customer instance | | C. An existing engine (FET) as a CLI | Planninq writes FET's XML and runs the binary | Mature for schools | A binary on the Nextcloud host, GPL-3 alongside EUPL, a file format we do not own | -Chosen for this change: **A**, behind an interface. `TimetableSolver::solve(SolverInput $input, -int $seconds): SolverResult` is the only thing the rest of the code calls. `SolverInput` is a plain +Chosen for this change: **A**, behind an interface (Ruben, DECISIONS row 21: PHP local search now, a +CP-SAT sidecar later, chosen per instance). `TimetableSolver::solve(SolverInput $input, int $seconds, +int $seed, array $start = []): SolverResult` is the only thing the rest of the code calls (amended in +the section 5 PR: the seed makes a run repeatable, and `start` lets each 60-second step continue from +the best placements of the step before). The interface is bound in `Application::register`, which is +where a second engine is chosen. A search step is bounded by a move count as well as the seconds, so +the same seed gives the same result; the solver also counts every lesson that breaks a hard wish in its +own cost and ends with a repair pass that takes such lessons off the grid, naming the wish, so a +generated scenario never breaks a hard wish of any kind. `SolverInput` is a plain JSON-serialisable contract (periods, rooms, teachers, groups, lessons, wishes), so B can be added later as a second implementation without a data migration. Whether B should be built as well is a question for Ruben (below). diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 2b566b9e..14aa94b5 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -25,9 +25,9 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 5. Solver and the background run -- [ ] 5.1 `TimetableSolver` interface, `SolverInput`/`SolverResult` value objects, `LocalSearchSolver` (greedy most-constrained-first, then simulated annealing on the scorer, seeded). Verify: PHPUnit: a feasible fixture places everything with 0 hard breaches; an infeasible hard wish leaves one lesson unplaced naming that wish; a soft wish is kept when it costs nothing; same seed gives the same result; a 600-lesson fixture finishes inside 60 seconds. -- [ ] 5.2 `POST /api/timetable/scenarios/{id}/generate` (admin) and `GenerateTimetableScenario` queued job in 60-second steps up to the budget (design decision 6). Verify: PHPUnit on the job (re-queues while improving, stops at the budget, stores `failed` with the reason on an exception); controller refuses a non-admin. -- [ ] 5.3 Scenario list and scenario page (status, progress, unplaced lessons with the blocking wish, broken soft wishes). Set `tt-hard-soft-wishes` built. Verify: Playwright "generate a scenario that respects a hard wish". +- [x] 5.1 `TimetableSolver` interface, `SolverInput`/`SolverResult` value objects, `LocalSearchSolver` (greedy most-constrained-first, then simulated annealing on the scorer, seeded). Verify: PHPUnit: a feasible fixture places everything with 0 hard breaches; an infeasible hard wish leaves one lesson unplaced naming that wish; a soft wish is kept when it costs nothing; same seed gives the same result; a 600-lesson fixture finishes inside 60 seconds. +- [x] 5.2 `POST /api/timetable/scenarios/{id}/generate` (admin) and `GenerateTimetableScenario` queued job in 60-second steps up to the budget (design decision 6). Verify: PHPUnit on the job (re-queues while improving, stops at the budget, stores `failed` with the reason on an exception); controller refuses a non-admin. +- [x] 5.3 Scenario list and scenario page (status, progress, unplaced lessons with the blocking wish, broken soft wishes). Set `tt-hard-soft-wishes` built. Verify: Playwright "generate a scenario that respects a hard wish". Built (section 5 PR): index page `TimetableScenarios` and detail page `TimetableScenarioDetail`, whose `sections` slot is `src/components/TimetableScenarioSections.vue` (no new custom page); vitest `timetableScenarios.spec.js`. ## 6. Imported scenarios diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 31c287ca..f9fc2c54 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -9012,10 +9012,11 @@ "timeedit": "docs read 2026-09-27: https://www.academy.timeedit.com/products/autopilot 'Employ the most sophisticated TimeEdit scheduling concepts as either hard or soft constraints in your auto-scheduling process' ; https://www.academy.timeedit.com/product-updates/119928309 'exact start time and weekday(s) when importing activities, letting you use these when scheduling as soft or hard constraints'" }, "built": { - "state": "specified", - "evidence": "Nothing in planninq or learniq generates a timetable: planninq stores lessons delivered by a rostering system (lib/Service/TimetableSessionService.php upsert, drafts and publish) and places none. Ruben decided on 29 Sep 2026 to build a generator in planninq (build-all DECISIONS.md row 17), reversing the 28 Sep decided-no and the learniq non-goal; learniq decided no in ConductionNL/learniq#1493 before that decision.", + "state": "built", + "evidence": "Wishes: timetableWish schema (appliesTo, reference, kind, periods, limit, strength hard or soft, weight 1 to 3) edited on the TimetableWishes index page with src/dialogs/TimetableWishDialog.vue (period picker on the week grid). Generator: lib/Timetabling/LocalSearchSolver.php behind TimetableSolver (greedy most-constrained-first, simulated annealing on TimetableScorer, a repair pass so a generated scenario never breaks a hard wish; a lesson it cannot place names the blocking hard wish), run by POST /api/timetable/scenarios/{id}/generate and the GenerateTimetableScenario queued job in 60-second steps up to the time budget; TimetableScenarioDetail page shows unplaced lessons and broken soft wishes. Tests: LocalSearchSolverTest (feasible week keeps the hard wish, impossible hard wish names itself, hard limit never broken, free soft wish kept, same seed same result, 600 lessons inside 60 seconds validated against the real timetableScenario fragment), TimetableGenerationServiceTest, TimetableScenarioControllerTest, vitest timetableWishes and timetableScenarios.", "owner": "ConductionNL/planninq", - "change": "openspec/changes/timetabling-generator" + "change": "openspec/changes/timetabling-generator", + "readOn": "2026-09-30" }, "provider": "learniq", "providerHow": "read-from-code", diff --git a/src/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue new file mode 100644 index 00000000..fa0c1b91 --- /dev/null +++ b/src/components/TimetableScenarioSections.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/manifest.json b/src/manifest.json index 10826b84..dbaef61d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -56,7 +56,8 @@ ] }, { "id": "Timesheet", "label": "Timesheet", "icon": "ClockOutline", "route": "Timesheet", "order": 50 }, { "id": "TimetableMenu", "label": "Timetable", "icon": "CalendarClock", "route": "TimetableWishes", "order": 55, "permission": "admin", "children": [ - { "id": "TimetableWishesMenu", "label": "Wishes", "icon": "CalendarClock", "route": "TimetableWishes" } + { "id": "TimetableWishesMenu", "label": "Wishes", "icon": "CalendarClock", "route": "TimetableWishes" }, + { "id": "TimetableScenariosMenu", "label": "Scenarios", "icon": "CalendarClock", "route": "TimetableScenarios" } ] }, { "id": "Documentation", "label": "Documentation", "icon": "BookOpenVariantOutline", "href": "https://planix.conduction.nl", "section": "footer", "order": 90 }, { "id": "StoreMenu", "label": "Store", "icon": "StoreOutline", "route": "Store", "section": "footer", "order": 92 }, @@ -254,6 +255,16 @@ ], "defaultSort": [{ "field": "reference", "order": "asc" }] }, "slots": { "form-dialog": "TimetableWishDialog" } }, + { "id": "TimetableScenarios", "_note": "The timetable scenarios, generated or taken from the current timetable, with their status and window. Admins create, edit and delete them, which OpenRegister enforces on the timetableScenario schema, so the page is a declarative index; a row opens TimetableScenarioDetail.", "route": "/timetable/scenarios", "type": "index", "title": "Timetable scenarios", "config": { + "register": "planninq", + "schema": "timetableScenario", + "columns": ["title", "source", "status", "windowFrom", "windowTo"], + "defaultSort": [{ "field": "windowFrom", "order": "desc" }] + } }, + { "id": "TimetableScenarioDetail", "_note": "One timetable scenario: its fields through the declarative detail page, and in its sections slot the generator run (status, progress, Generate), the lessons without a place with the hard wish that blocked them, and the broken soft wishes.", "route": "/timetable/scenarios/:id", "type": "detail", "title": "Scenario", "config": { + "register": "planninq", + "schema": "timetableScenario" + }, "slots": { "sections": "TimetableScenarioSections" } }, { "id": "Boards", "_note": "Board picker: the projects a user can open a board for, as entry points rather than records. The cards route to /projects/:id and expose no record actions.", "route": "/boards", "type": "custom", "title": "Boards", "component": "Boards", "config": {} }, { "id": "PortfolioStatus", "_note": "One portfolio's projects with their lifecycle status, progress, dates, budget and six aspect statuses, under a roll-up per aspect with the worst state and a No report count. It combines the projects, their tasks and an admin setting into one table and a roll-up, which no index or dashboard widget expresses.", "route": "/portfolio/status", "type": "custom", "title": "Portfolio status", "component": "PortfolioStatus", "config": {} }, { "id": "PortfolioFinance", "_note": "Money across one portfolio: a row per project with budget, commitments, actual cost including labour from booked time, forecast and what remains, and the totals, for the projects whose money the viewer may see. It combines finance lines, time entries and projects into computed rows and a cross-checked total, which no index or dashboard widget expresses.", "route": "/portfolio/finance", "type": "custom", "title": "Portfolio finance", "component": "PortfolioFinance", "config": {} }, diff --git a/src/registry.js b/src/registry.js index eb447a0b..400349b1 100644 --- a/src/registry.js +++ b/src/registry.js @@ -16,11 +16,12 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. */ -import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' // NOTE: the dashboard's DashboardPanels component is NOT registered here. A // dashboard widget TYPE resolves against the library's own widget catalog via // registerDashboardWidget() in main.js; this registry is for page components // and slot overrides only. +import TimetableScenarioSections from './components/TimetableScenarioSections.vue' +import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' import Boards from './views/Boards.vue' import MyWork from './views/MyWork.vue' import Portfolio from './views/Portfolio.vue' @@ -79,5 +80,6 @@ export default { ProjectTimeline: page(ProjectTimeline), TaskDetail: page(TaskDetail), Timesheet: page(Timesheet), + TimetableScenarioSections: slot(TimetableScenarioSections), TimetableWishDialog: slot(TimetableWishDialog), } diff --git a/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js new file mode 100644 index 00000000..1f937326 --- /dev/null +++ b/src/utils/timetableScenarios.js @@ -0,0 +1,86 @@ +/** + * Helpers for the timetable scenario page (timetabling-generator, section 5.3). + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +/** + * Whether a run is still going, so the page keeps reading the scenario. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {boolean} True while queued or running. + */ +export function isRunning(scenario) { + return ['queued', 'running'].includes(scenario?.status) +} + +/** + * The share of the time budget spent, from 0 to 100. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {number} The percentage, whole. + */ +export function progressPercent(scenario) { + if (scenario?.status === 'done' || scenario?.status === 'published') { + return 100 + } + const budget = Number(scenario?.metrics?.budgetSeconds ?? 0) + const spent = Number(scenario?.metrics?.secondsSpent ?? 0) + if (budget <= 0) { + return 0 + } + return Math.min(100, Math.round((spent / budget) * 100)) +} + +/** + * A short description of a wish, from the scenario's own input snapshot. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} wish The wish as the input keeps it. + * @return {{appliesTo: string, reference: string, kind: string, periods: string[]}|null} The parts, or null when unknown. + */ +export function wishSummary(wish) { + if (!wish) { + return null + } + return { + appliesTo: wish.appliesTo ?? '', + reference: wish.reference ?? '', + kind: wish.kind ?? '', + periods: Array.isArray(wish.periods) ? wish.periods : [], + } +} + +/** + * The unplaced lessons with the hard wish that blocked each. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {Array<{lesson: string, wish: object|null, reason: string}>} One row per lesson. + */ +export function unplacedRows(scenario) { + const wishes = new Map((scenario?.input?.wishes ?? []).map((wish) => [String(wish.id), wish])) + return (scenario?.unplaced ?? []).map((row) => ({ + lesson: row.lesson, + wish: wishSummary(row.wish ? wishes.get(String(row.wish)) ?? { reference: String(row.wish) } : null), + reason: row.reason ?? (row.wish ? 'hardWish' : 'noFreePlace'), + })) +} + +/** + * The broken soft wishes, heaviest first, with the lessons that break them. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {Array<{wish: object|null, weight: number, lessons: string[]}>} One row per wish. + */ +export function brokenSoftRows(scenario) { + const wishes = new Map((scenario?.input?.wishes ?? []).map((wish) => [String(wish.id), wish])) + return (scenario?.brokenWishes ?? []) + .filter((row) => row.strength !== 'hard' && row.weight !== null) + .map((row) => ({ wish: wishSummary(wishes.get(String(row.wish)) ?? { reference: String(row.wish) }), weight: Number(row.weight ?? 1), lessons: row.lessons ?? [] })) + .sort((a, b) => (b.weight * b.lessons.length) - (a.weight * a.lessons.length)) +} diff --git a/tests/e2e/timetable-generator.spec.ts b/tests/e2e/timetable-generator.spec.ts new file mode 100644 index 00000000..0db8186c --- /dev/null +++ b/tests/e2e/timetable-generator.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * E2E coverage for a generator run (timetabling-generator, section 5). + * + * @e2e timetable-generator::generate-a-scenario-that-respects-a-hard-wish + * + * The run itself is a queued job, so the test starts it through the endpoint, + * runs the background jobs with `occ background-job:worker` when the harness + * allows it (NC_OCC), and reads the stored scenario. The page is checked for the + * status and the two lists. + */ + +import type { APIRequestContext } from '@playwright/test' + +import { expect, request, test } from '@playwright/test' +import { execSync } from 'node:child_process' +import { BASE_URL } from './base-url.ts' +import { PLANNINQ_ROOT } from './nav.ts' + +const RUN = Date.now().toString(36) +const OBJECTS = '/index.php/apps/openregister/api/objects/planninq' +const API = '/index.php/apps/planninq/api' + +async function admin(): Promise { + return request.newContext({ + baseURL: BASE_URL, + httpCredentials: { + username: process.env.NC_ADMIN_USER ?? process.env.ADMIN_USER ?? 'admin', + password: process.env.NC_ADMIN_PASS ?? process.env.ADMIN_PASSWORD ?? 'admin', + send: 'always', + }, + extraHTTPHeaders: { 'Content-Type': 'application/json', 'OCS-APIRequest': 'true', Accept: 'application/json' }, + }) +} + +test.describe('Timetable generator', () => { + let api: APIRequestContext + const created: string[] = [] + + test.beforeAll(async () => { + api = await admin() + }) + + test.afterAll(async () => { + for (const path of created) { + await api.delete(path, { failOnStatusCode: false }) + } + await api.dispose() + }) + + test('generate a scenario that respects a hard wish', async ({ page }) => { + const upload = await api.post(`${API}/timetable/input/upload`, { + data: { + rooms: 'reference,capacity,type\nB12,30,classroom\nB13,30,classroom', + activities: `group,subject,teacher,lessons per week,lesson length,room type\n3A,English,klaas-${RUN},3,1,classroom\n3B,Maths,noor-${RUN},2,1,classroom`, + }, + }) + expect(upload.ok(), 'upload the sheets').toBe(true) + + const wish = await api.post(`${OBJECTS}/timetableWish`, { + data: { appliesTo: 'teacher', reference: `klaas-${RUN}`, kind: 'unavailable', periods: ['wed-5', 'wed-6', 'wed-7', 'wed-8'], strength: 'hard' }, + }) + const wishBody = await wish.json() + created.push(`${OBJECTS}/timetableWish/${wishBody.id ?? wishBody['@self']?.id}`) + + const scenario = await api.post(`${OBJECTS}/timetableScenario`, { + data: { title: `Try ${RUN}`, source: 'generated', weekOf: '2026-10-05', windowFrom: '2026-10-05', windowTo: '2026-10-30', status: 'queued' }, + }) + const scenarioBody = await scenario.json() + const id = scenarioBody.id ?? scenarioBody['@self']?.id + created.push(`${OBJECTS}/timetableScenario/${id}`) + + const generate = await api.post(`${API}/timetable/scenarios/${id}/generate`) + expect(generate.status()).toBe(202) + + if (process.env.NC_OCC) { + execSync(`${process.env.NC_OCC} background-job:worker -t 120 'OCA\\Planninq\\BackgroundJob\\GenerateTimetableScenario'`, { stdio: 'ignore' }) + const stored = await (await api.get(`${OBJECTS}/timetableScenario/${id}`)).json() + expect(['running', 'done']).toContain(stored.status) + expect(stored.metrics.hardWishesBroken).toBe(0) + for (const row of stored.placements.filter((p: { lesson: string }) => p.lesson.startsWith('3A:English'))) { + expect(['wed-5', 'wed-6', 'wed-7', 'wed-8']).not.toContain(row.period) + } + } + + await page.goto(`${PLANNINQ_ROOT}timetable/scenarios/${id}`) + await expect(page.getByTestId('scenario-sections')).toBeVisible() + await expect(page.getByTestId('scenario-status')).not.toBeEmpty() + }) +}) diff --git a/tests/unit/Controller/TimetableScenarioControllerTest.php b/tests/unit/Controller/TimetableScenarioControllerTest.php new file mode 100644 index 00000000..d86bc75d --- /dev/null +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -0,0 +1,110 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Controller; + +use InvalidArgumentException; +use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; +use OCA\Planninq\Controller\TimetableScenarioController; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableGenerationService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * The generate endpoint. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioControllerTest extends TestCase { + + /** + * A controller for the given caller, with the given generation service and job list. + * + * @param bool $admin Whether the caller is an admin. + * @param TimetableGenerationService $generation The generation service. + * @param IJobList $jobList The job list. + * + * @return TimetableScenarioController + */ + private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList): TimetableScenarioController { + $settings = $this->createMock(SettingsService::class); + $settings->method('isCurrentUserAdmin')->willReturn($admin); + return new TimetableScenarioController(request: $this->createMock(IRequest::class), generation: $generation, jobList: $jobList, settingsService: $settings); + }//end controller() + + /** + * A non-admin is refused and nothing is queued. + * + * @return void + */ + public function testANonAdminIsRefused(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->expects($this->never())->method('queue'); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->never())->method('add'); + + $response = $this->controller(admin: false, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertNotEmpty((new ReflectionMethod(TimetableScenarioController::class, 'generate'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminIsRefused() + + /** + * An admin's run is queued and answered with 202. + * + * @return void + */ + public function testAnAdminQueuesARun(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->method('queue')->willReturn(['status' => 'queued', 'input' => ['lessons' => [['key' => 'a'], ['key' => 'b']]]]); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->once())->method('add')->with(GenerateTimetableScenario::class, ['scenario' => 's-1']); + + $response = $this->controller(admin: true, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_ACCEPTED, actual: $response->getStatus()); + self::assertSame(expected: ['id' => 's-1', 'status' => 'queued', 'lessons' => 2], actual: $response->getData()); + }//end testAnAdminQueuesARun() + + /** + * A scenario the service refuses answers 400 with the reason and queues nothing. + * + * @return void + */ + public function testARefusedScenarioAnswersBadRequest(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->method('queue')->willThrowException(new InvalidArgumentException('No activities: learniq did not answer and no CSV was uploaded')); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->never())->method('add'); + + $response = $this->controller(admin: true, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); + self::assertStringContainsString(needle: 'No activities', haystack: $response->getData()['error']); + }//end testARefusedScenarioAnswersBadRequest() +}//end class diff --git a/tests/unit/Service/TimetableGenerationServiceTest.php b/tests/unit/Service/TimetableGenerationServiceTest.php new file mode 100644 index 00000000..0bac2bf7 --- /dev/null +++ b/tests/unit/Service/TimetableGenerationServiceTest.php @@ -0,0 +1,320 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use InvalidArgumentException; +use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; +use OCA\Planninq\Service\TimetableGenerationService; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Service\TimetableScenarioStore; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCA\Planninq\Timetabling\LocalSearchSolver; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\SolverResult; +use OCA\Planninq\Timetabling\TimetableSolver; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use RuntimeException; + +/** + * The real store over an in-memory ObjectService, the real input builder over uploaded sheets, the real solver. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableGenerationServiceTest extends TestCase { + use RegisterSchemaValidation; + + /** + * App config values, in memory. + * + * @var array + */ + private array $config = []; + + /** + * The fake ObjectService. + * + * @var object + */ + private object $objects; + + /** + * Uploaded sheets (3A and 3B, two teachers, two rooms), a hard wish, a small grid, a one-minute budget and a queued scenario. + * + * @return void + */ + protected function setUp(): void { + $this->config = [ + TimetableGridService::GRID_KEY => '{"days":["mon","wed"],"periods":[{"start":"08:30","end":"09:20"},{"start":"09:20","end":"10:10"},{"start":"10:10","end":"11:00"}]}', + TimetableGridService::BUDGET_KEY => '1', + ]; + $this->objects = new class { + /** + * Stored objects by id. + * + * @var array> + */ + public array $stored = []; + + /** + * Saved payloads, in order. + * + * @var array> + */ + public array $saves = []; + + /** + * OpenRegister's find. + * + * @param string $id The id. + * + * @return array|null + */ + public function find(string $id, mixed ...$rest): ?array { + return ($this->stored[$id] ?? null); + } + + /** + * OpenRegister's saveObject. + * + * @param array $object The data. + * + * @return array + */ + public function saveObject(array $object, mixed ...$rest): array { + $this->saves[] = $object; + $this->stored[(string)$rest['uuid']] = array_merge($object, ['id' => (string)$rest['uuid']]); + return $this->stored[(string)$rest['uuid']]; + } + + /** + * OpenRegister's searchObjectsBySlug. + * + * @return array + */ + public function searchObjectsBySlug(string $registerSlug, string $schemaSlug, mixed ...$rest): array { + return ['results' => array_values(array_filter($this->stored, static fn (array $row): bool => isset($row['strength'])))]; + } + }; + $this->objects->stored['w-1'] = ['id' => 'w-1', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-3'], 'strength' => 'hard']; + $this->objects->stored['s-1'] = [ + 'id' => 's-1', + 'title' => 'First try', + 'source' => 'generated', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-30', + 'status' => 'done', + ]; + + $this->builder()->storeUpload( + rooms: [['reference' => 'r-1', 'capacity' => 30, 'type' => 'classroom'], ['reference' => 'r-2', 'capacity' => 30, 'type' => 'classroom']], + activities: [ + ['group' => '3A', 'subject' => 'English', 'teacher' => 'klaas', 'lessonsPerWeek' => 3, 'lessonLength' => 1, 'roomType' => 'classroom'], + ['group' => '3B', 'subject' => 'Maths', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 1, 'roomType' => 'classroom'], + ] + ); + }//end setUp() + + /** + * The input builder over the in-memory config, with an event nobody answers. + * + * @return TimetableInputBuilder + */ + private function builder(): TimetableInputBuilder { + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->config[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->config[$key] = $value; + return true; + } + ); + + return new TimetableInputBuilder(dispatcher: $this->createMock(IEventDispatcher::class), appConfig: $config, grid: new TimetableGridService(appConfig: $config)); + }//end builder() + + /** + * The service with the given solver. + * + * @param TimetableSolver|null $solver The solver; the real one when null. + * + * @return TimetableGenerationService + */ + private function service(?TimetableSolver $solver=null): TimetableGenerationService { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->objects); + $builder = $this->builder(); + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback(fn (string $app, string $key, string $default = ''): string => ($this->config[$key] ?? $default)); + + return new TimetableGenerationService( + store: new TimetableScenarioStore(container: $container), + inputBuilder: $builder, + grid: new TimetableGridService(appConfig: $config), + solver: ($solver ?? new LocalSearchSolver()) + ); + }//end service() + + /** + * The scenario as stored now. + * + * @return array + */ + private function scenario(): array { + return $this->objects->stored['s-1']; + }//end scenario() + + /** + * Queueing snapshots the input with the wish, picks a seed and stores status queued; the stored payload passes the real schema. + * + * @return void + */ + public function testQueueSnapshotsTheInput(): void { + $queued = $this->service()->queue(id: 's-1'); + + self::assertSame(expected: 'queued', actual: $queued['status']); + self::assertCount(expectedCount: 5, haystack: $queued['input']['lessons']); + self::assertSame(expected: 'w-1', actual: $queued['input']['wishes'][0]['id']); + self::assertIsInt(actual: $queued['seed']); + self::assertSame(expected: 60, actual: $queued['metrics']['budgetSeconds']); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: end($this->objects->saves))); + }//end testQueueSnapshotsTheInput() + + /** + * An imported scenario and an unknown id are refused. + * + * @return void + */ + public function testQueueRefusesAnImportedOrUnknownScenario(): void { + $this->objects->stored['s-1']['source'] = 'imported'; + try { + $this->service()->queue(id: 's-1'); + self::fail('An imported scenario was queued.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString(needle: 'generated', haystack: $e->getMessage()); + } + + $this->expectException(InvalidArgumentException::class); + $this->service()->queue(id: 'nope'); + }//end testQueueRefusesAnImportedOrUnknownScenario() + + /** + * A step stores the placements with no clash and no broken hard wish, and asks for another step while it improves; + * the next step does not improve on a perfect week and ends the run as done. The stored payloads pass the real schema. + * + * @return void + */ + public function testAStepStoresTheRunAndRequeuesWhileImproving(): void { + $service = $this->service(); + $service->queue(id: 's-1'); + + $more = $service->step(id: 's-1'); + $scenario = $this->scenario(); + self::assertCount(expectedCount: 5, haystack: $scenario['placements']); + self::assertSame(expected: 0, actual: $scenario['metrics']['clashes']); + self::assertSame(expected: 0, actual: $scenario['metrics']['hardWishesBroken']); + self::assertSame(expected: 1, actual: $scenario['metrics']['steps']); + self::assertSame(expected: ($more === true ? 'running' : 'done'), actual: $scenario['status']); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: end($this->objects->saves))); + + while ($more === true) { + $more = $service->step(id: 's-1'); + } + + self::assertSame(expected: 'done', actual: $this->scenario()['status']); + self::assertFalse(condition: $service->step(id: 's-1'), message: 'A done run takes no more steps.'); + }//end testAStepStoresTheRunAndRequeuesWhileImproving() + + /** + * A run whose budget is spent stops, even while it would still improve. + * + * @return void + */ + public function testTheRunStopsAtTheBudget(): void { + $solver = $this->createMock(TimetableSolver::class); + $cost = 1000; + $solver->method('solve')->willReturnCallback( + static function (SolverInput $input, int $seconds) use (&$cost): SolverResult { + $cost--; + return new SolverResult(placements: [], unplaced: [], brokenWishes: [], metrics: ['clashes' => 0], cost: $cost, iterations: 1); + } + ); + $service = $this->service(solver: $solver); + $service->queue(id: 's-1'); + $this->objects->stored['s-1']['metrics']['budgetSeconds'] = 2; + + $steps = 1; + while ($service->step(id: 's-1') === true) { + $steps++; + } + + self::assertLessThanOrEqual(expected: 2, actual: $steps); + self::assertSame(expected: 'done', actual: $this->scenario()['status']); + self::assertGreaterThanOrEqual(expected: 2, actual: $this->scenario()['metrics']['secondsSpent']); + }//end testTheRunStopsAtTheBudget() + + /** + * An error in a step stores the run as failed with the reason, and no further step runs. + * + * @return void + */ + public function testAnErrorStoresFailedWithTheReason(): void { + $solver = $this->createMock(TimetableSolver::class); + $solver->method('solve')->willThrowException(new RuntimeException('The grid has no periods.')); + $service = $this->service(solver: $solver); + $service->queue(id: 's-1'); + + self::assertFalse(condition: $service->step(id: 's-1')); + self::assertSame(expected: 'failed', actual: $this->scenario()['status']); + self::assertSame(expected: 'The grid has no periods.', actual: $this->scenario()['reason']); + }//end testAnErrorStoresFailedWithTheReason() + + /** + * The job queues its next step only when the step asks for one. + * + * @return void + */ + public function testTheJobRequeuesOnlyWhileTheRunGoesOn(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->method('step')->willReturnOnConsecutiveCalls(true, false); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->once())->method('add')->with(GenerateTimetableScenario::class, ['scenario' => 's-1']); + $job = new GenerateTimetableScenario(time: $this->createMock(ITimeFactory::class), generation: $generation, jobList: $jobList); + + self::assertTrue(condition: $job->runStep(argument: ['scenario' => 's-1'])); + self::assertFalse(condition: $job->runStep(argument: ['scenario' => 's-1'])); + self::assertFalse(condition: $job->runStep(argument: [])); + }//end testTheJobRequeuesOnlyWhileTheRunGoesOn() +}//end class diff --git a/tests/unit/Timetabling/LocalSearchSolverTest.php b/tests/unit/Timetabling/LocalSearchSolverTest.php new file mode 100644 index 00000000..667088fe --- /dev/null +++ b/tests/unit/Timetabling/LocalSearchSolverTest.php @@ -0,0 +1,295 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Timetabling; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCA\Planninq\Timetabling\LocalSearchSolver; +use OCA\Planninq\Timetabling\PlacementOptions; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\TimetableScorer; +use OCA\Planninq\Timetabling\WishChecker; +use PHPUnit\Framework\TestCase; + +/** + * The solver on small hand-made weeks and one generated school week. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ +class LocalSearchSolverTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The period keys of a week of the given days and periods a day. + * + * @param array $days The days. + * @param int $perDay Periods a day. + * + * @return array + */ + private function periods(array $days, int $perDay): array { + $keys = []; + foreach ($days as $day) { + for ($number = 1; $number <= $perDay; $number++) { + $keys[] = $day.'-'.$number; + } + } + + return $keys; + }//end periods() + + /** + * A lesson row. + * + * @param string $key The lesson key. + * @param string $teacher The teacher. + * @param int $length Periods it takes. + * + * @return array + */ + private function lesson(string $key, string $teacher, int $length=1): array { + [$group, $subject] = explode(':', $key); + return [ + 'key' => $key, + 'activity' => $group.':'.$subject, + 'group' => $group, + 'subject' => $subject, + 'teacher' => $teacher, + 'roomType' => 'classroom', + 'length' => $length, + ]; + }//end lesson() + + /** + * A small week: 3A and 3B, klaas and noor, two classrooms, Monday and Wednesday, four periods each. + * + * @param array> $wishes The wishes. + * + * @return SolverInput + */ + private function smallWeek(array $wishes=[]): SolverInput { + return new SolverInput( + periods: $this->periods(days: ['mon', 'wed'], perDay: 4), + rooms: [ + ['reference' => 'r-1', 'capacity' => 30, 'type' => 'classroom'], + ['reference' => 'r-2', 'capacity' => 30, 'type' => 'classroom'], + ], + lessons: [ + $this->lesson('3A:English:1', 'klaas'), + $this->lesson('3A:English:2', 'klaas'), + $this->lesson('3A:English:3', 'klaas'), + $this->lesson('3B:English:1', 'klaas'), + $this->lesson('3A:Maths:1', 'noor', 2), + $this->lesson('3B:Maths:1', 'noor'), + $this->lesson('3B:Maths:2', 'noor'), + ], + wishes: $wishes, + source: 'csv', + ); + }//end smallWeek() + + /** + * A solver that tries a fixed number of moves. + * + * @param int $iterations Moves per call. + * + * @return LocalSearchSolver + */ + private function solver(int $iterations=300): LocalSearchSolver { + return new LocalSearchSolver( + scorer: new TimetableScorer(), + options: new PlacementOptions(), + wishes: new WishChecker(), + iterations: $iterations + ); + }//end solver() + + /** + * Scenario "Generate a scenario that respects a hard wish": klaas never on Wednesday + * periods 3 and 4, everything placed, no clash, no hard wish broken. + * + * @return void + */ + public function testAFeasibleWeekPlacesEverythingAndKeepsTheHardWish(): void { + $wish = ['id' => 'w-klaas', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-3', 'wed-4'], 'strength' => 'hard']; + $input = $this->smallWeek(wishes: [$wish]); + $result = $this->solver()->solve(input: $input, seconds: 20, seed: 7); + + self::assertSame(expected: [], actual: $result->unplaced); + self::assertCount(expectedCount: 7, haystack: $result->placements); + self::assertSame(expected: 0, actual: $result->metrics['clashes']); + self::assertSame(expected: 0, actual: $result->metrics['hardWishesBroken']); + $klaas = array_filter($result->placements, static fn (array $row): bool => str_contains($row['lesson'], 'English')); + foreach ($klaas as $row) { + self::assertNotContains(needle: $row['period'], haystack: ['wed-3', 'wed-4']); + } + }//end testAFeasibleWeekPlacesEverythingAndKeepsTheHardWish() + + /** + * Scenario "A lesson that cannot be placed is listed with its reason": klaas is unavailable + * all week but for one period, so one of his four lessons fits and three are unplaced + * naming the wish. With three free periods, exactly one lesson is left. + * + * @return void + */ + public function testAnImpossibleHardWishLeavesALessonUnplacedNamingTheWish(): void { + $free = ['mon-1', 'mon-2', 'mon-3']; + $closed = array_values(array_diff($this->periods(days: ['mon', 'wed'], perDay: 4), $free)); + $wish = ['id' => 'w-klaas', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => $closed, 'strength' => 'hard']; + $result = $this->solver()->solve(input: $this->smallWeek(wishes: [$wish]), seconds: 20, seed: 7); + + self::assertCount(expectedCount: 1, haystack: $result->unplaced); + self::assertStringContainsString(needle: 'English', haystack: $result->unplaced[0]['lesson']); + self::assertSame(expected: 'w-klaas', actual: $result->unplaced[0]['wish']); + self::assertSame(expected: 0, actual: $result->metrics['hardWishesBroken']); + self::assertSame(expected: 0, actual: $result->metrics['clashes']); + }//end testAnImpossibleHardWishLeavesALessonUnplacedNamingTheWish() + + /** + * A hard wish of a kind the options cannot express (3A at most one lesson a day, four lessons, two days) is kept + * by the search, or its breaking lessons are taken off and name it: never broken. + * + * @return void + */ + public function testAHardLimitIsNeverBroken(): void { + $wish = ['id' => 'w-max', 'appliesTo' => 'group', 'reference' => '3A', 'kind' => 'maxPerDay', 'limit' => 1, 'strength' => 'hard']; + $result = $this->solver()->solve(input: $this->smallWeek(wishes: [$wish]), seconds: 20, seed: 3); + + self::assertSame(expected: 0, actual: $result->metrics['hardWishesBroken']); + foreach ($result->unplaced as $row) { + self::assertSame(expected: 'w-max', actual: $row['wish']); + self::assertSame(expected: 'hardWish', actual: $row['reason']); + } + + self::assertCount(expectedCount: 5, haystack: $result->placements); + self::assertCount(expectedCount: 2, haystack: $result->unplaced); + }//end testAHardLimitIsNeverBroken() + + /** + * A soft wish that costs nothing to keep is kept: noor would rather not teach on Monday, + * and Wednesday has room for all her lessons. + * + * @return void + */ + public function testASoftWishIsKeptWhenItCostsNothing(): void { + $wish = [ + 'id' => 'w-noor', + 'appliesTo' => 'teacher', + 'reference' => 'noor', + 'kind' => 'avoid', + 'periods' => $this->periods(days: ['mon'], perDay: 4), + 'strength' => 'soft', + 'weight' => 2, + ]; + $result = $this->solver()->solve(input: $this->smallWeek(wishes: [$wish]), seconds: 20, seed: 11); + + self::assertSame(expected: 0, actual: $result->metrics['softWishesBroken']); + self::assertSame(expected: [], actual: $result->brokenWishes); + self::assertSame(expected: [], actual: $result->unplaced); + }//end testASoftWishIsKeptWhenItCostsNothing() + + /** + * The same seed gives the same placements; a different seed may differ but is as valid. + * + * @return void + */ + public function testTheSameSeedGivesTheSameResult(): void { + $input = $this->smallWeek(); + $one = $this->solver()->solve(input: $input, seconds: 20, seed: 42); + $two = $this->solver()->solve(input: $input, seconds: 20, seed: 42); + $other = $this->solver()->solve(input: $input, seconds: 20, seed: 43); + + self::assertSame(expected: $one->placements, actual: $two->placements); + self::assertSame(expected: $one->iterations, actual: $two->iterations); + self::assertSame(expected: 0, actual: $other->metrics['clashes']); + }//end testTheSameSeedGivesTheSameResult() + + /** + * A step continues from the placements of an earlier step and never ends worse than them. + * + * @return void + */ + public function testAStepContinuesFromTheBestSoFar(): void { + $input = $this->smallWeek(); + $first = $this->solver(iterations: 50)->solve(input: $input, seconds: 20, seed: 5); + $next = $this->solver(iterations: 50)->solve(input: $input, seconds: 20, seed: 6, start: $first->placements); + + self::assertLessThanOrEqual(expected: $first->cost, actual: $next->cost); + }//end testAStepContinuesFromTheBestSoFar() + + /** + * A school week of 600 lessons (30 groups of 20 lessons, 60 teachers, 30 classrooms, 5 days of 8 periods) + * is placed without a clash inside 60 seconds, and the result fits the real timetableScenario schema. + * + * @return void + */ + public function testSixHundredLessonsFinishInsideAMinute(): void { + $lessons = []; + for ($group = 1; $group <= 30; $group++) { + foreach (['Dutch', 'English', 'Maths', 'Science'] as $index => $subject) { + for ($number = 1; $number <= 5; $number++) { + $lessons[] = $this->lesson('G'.$group.':'.$subject.':'.$number, 't'.((($group - 1) % 15) + 1).'-'.$index); + } + } + } + + $rooms = []; + for ($room = 1; $room <= 30; $room++) { + $rooms[] = ['reference' => 'r-'.$room, 'capacity' => 30, 'type' => 'classroom']; + } + + $input = new SolverInput( + periods: $this->periods(days: ['mon', 'tue', 'wed', 'thu', 'fri'], perDay: 8), + rooms: $rooms, + lessons: $lessons, + wishes: [['id' => 'w-t1', 'appliesTo' => 'teacher', 'reference' => 't1-0', 'kind' => 'unavailable', 'periods' => ['fri-7', 'fri-8'], 'strength' => 'hard']], + source: 'csv', + ); + $began = microtime(true); + $result = $this->solver(iterations: 200)->solve(input: $input, seconds: 50, seed: 1); + + self::assertLessThan(expected: 60.0, actual: (microtime(true) - $began)); + self::assertCount(expectedCount: 600, haystack: $result->placements); + self::assertSame(expected: 0, actual: $result->metrics['clashes']); + self::assertSame(expected: 0, actual: $result->metrics['hardWishesBroken']); + + $scenario = array_merge( + [ + 'title' => 'Generated week', + 'source' => 'generated', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-30', + 'status' => 'done', + 'seed' => 1, + 'input' => $input->toArray(), + ], + $result->toScenario() + ); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario)); + }//end testSixHundredLessonsFinishInsideAMinute() +}//end class diff --git a/tests/vitest/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js new file mode 100644 index 00000000..71bcd8ea --- /dev/null +++ b/tests/vitest/timetableScenarios.spec.js @@ -0,0 +1,61 @@ +/** + * Vitest tests for the timetable scenario page helpers (timetabling-generator, section 5.3). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + */ +import { describe, expect, it } from 'vitest' +import { brokenSoftRows, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' + +const scenario = { + status: 'running', + metrics: { secondsSpent: 150, budgetSeconds: 600 }, + input: { + wishes: [ + { id: 'w-klaas', appliesTo: 'teacher', reference: 'klaas', kind: 'unavailable', periods: ['wed-5', 'wed-6'], strength: 'hard' }, + { id: 'w-noor', appliesTo: 'teacher', reference: 'noor', kind: 'avoid', periods: ['fri-8'], strength: 'soft', weight: 1 }, + { id: 'w-3a', appliesTo: 'group', reference: '3A', kind: 'noGaps', strength: 'soft', weight: 3 }, + ], + }, + unplaced: [ + { lesson: '3A:English:3', wish: 'w-klaas', reason: 'hardWishPeriods' }, + { lesson: '3B:Art:1', wish: null, reason: 'noFreePlace' }, + ], + brokenWishes: [ + { wish: 'w-noor', strength: 'soft', weight: 1, lessons: ['3B:Maths:1', '3B:Maths:2'] }, + { wish: 'w-3a', strength: 'soft', weight: 3, lessons: ['3A:English:1'] }, + ], +} + +describe('the run', () => { + it('is going while queued or running', () => { + expect(isRunning(scenario)).toBe(true) + expect(isRunning({ status: 'queued' })).toBe(true) + expect(isRunning({ status: 'done' })).toBe(false) + expect(isRunning(null)).toBe(false) + }) + + it('reports the share of the time budget spent', () => { + expect(progressPercent(scenario)).toBe(25) + expect(progressPercent({ status: 'running', metrics: { secondsSpent: 900, budgetSeconds: 600 } })).toBe(100) + expect(progressPercent({ status: 'done', metrics: {} })).toBe(100) + expect(progressPercent({ status: 'queued' })).toBe(0) + }) +}) + +describe('the lists (scenario: a lesson that cannot be placed is listed with its reason)', () => { + it('names the hard wish that blocked each unplaced lesson', () => { + expect(unplacedRows(scenario)).toEqual([ + { lesson: '3A:English:3', wish: { appliesTo: 'teacher', reference: 'klaas', kind: 'unavailable', periods: ['wed-5', 'wed-6'] }, reason: 'hardWishPeriods' }, + { lesson: '3B:Art:1', wish: null, reason: 'noFreePlace' }, + ]) + }) + + it('lists broken soft wishes heaviest first with their weight', () => { + const rows = brokenSoftRows(scenario) + expect(rows.map((row) => [row.wish.reference, row.weight, row.lessons.length])).toEqual([['3A', 3, 1], ['noor', 1, 2]]) + }) + + it('keeps a wish that is no longer in the input readable by its id', () => { + expect(unplacedRows({ unplaced: [{ lesson: 'x', wish: 'gone' }] })[0].wish.reference).toBe('gone') + }) +}) From 9f232008571f0c07cc77fa94df2acf39d6398c42 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 05:36:52 +0200 Subject: [PATCH 06/14] fix(timetable): phpcs, phpmd static access and the job's spec tag --- .../GenerateTimetableScenario.php | 2 ++ lib/Service/TimetableGenerationService.php | 20 +++++++++++++++---- lib/Timetabling/LocalSearchSolver.php | 4 ++-- lib/Timetabling/SolverResult.php | 12 +++++------ lib/Timetabling/TimetableScorer.php | 13 ++++++++++++ 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/lib/BackgroundJob/GenerateTimetableScenario.php b/lib/BackgroundJob/GenerateTimetableScenario.php index e38c25df..a3d75afd 100644 --- a/lib/BackgroundJob/GenerateTimetableScenario.php +++ b/lib/BackgroundJob/GenerateTimetableScenario.php @@ -60,6 +60,8 @@ public function __construct( * @param mixed $argument `['scenario' => id]`. * * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 */ protected function run($argument): void { $this->runStep(argument: $argument); diff --git a/lib/Service/TimetableGenerationService.php b/lib/Service/TimetableGenerationService.php index e8bff589..132bd6ad 100644 --- a/lib/Service/TimetableGenerationService.php +++ b/lib/Service/TimetableGenerationService.php @@ -163,13 +163,18 @@ private function runStep(string $id, array $scenario): bool { $improved = ($previous === null || $result->cost < (int)$previous); $more = ($improved === true && $spent < $budget && $result->cost > 0); + $status = 'done'; + if ($more === true) { + $status = 'running'; + } + $this->store->save( id: $id, data: array_merge( $scenario, $result->toScenario(), [ - 'status' => ($more === true ? 'running' : 'done'), + 'status' => $status, 'metrics' => array_merge( $result->metrics, ['cost' => $result->cost, 'secondsSpent' => $spent, 'steps' => ((int)($metrics['steps'] ?? 0) + 1), 'budgetSeconds' => $budget] @@ -222,9 +227,16 @@ private function budgetSeconds(): int { * @return string As in `2026-2027`. */ private function academicYear(string $date): string { - $time = (strtotime($date) ?: time()); - $year = (int)date('Y', $time); - $start = ((int)date('n', $time) >= 8) ? $year : ($year - 1); + $time = strtotime($date); + if ($time === false) { + $time = time(); + } + + $start = (int)date('Y', $time); + if ((int)date('n', $time) < 8) { + $start--; + } + return $start.'-'.($start + 1); }//end academicYear() }//end class diff --git a/lib/Timetabling/LocalSearchSolver.php b/lib/Timetabling/LocalSearchSolver.php index 92556c83..8436ac91 100644 --- a/lib/Timetabling/LocalSearchSolver.php +++ b/lib/Timetabling/LocalSearchSolver.php @@ -266,7 +266,7 @@ private function restore(SearchState $state, string $key, ?array $option): void */ private function cost(SolverInput $input, SearchState $state): int { $score = $this->scorer->score(input: $input, placements: $state->placements()); - $cost = TimetableScorer::cost($score['metrics']); + $cost = $this->scorer->costOf(metrics: $score['metrics']); foreach ($score['brokenWishes'] as $broken) { if ($broken['strength'] === 'hard') { $cost += (count($broken['lessons']) * TimetableScorer::HARD_COST); @@ -346,7 +346,7 @@ private function result(SolverInput $input, SearchState $state, array $blockers, unplaced: $unplaced, brokenWishes: $score['brokenWishes'], metrics: $score['metrics'], - cost: TimetableScorer::cost($score['metrics']), + cost: $this->scorer->costOf(metrics: $score['metrics']), iterations: $iterations ); }//end result() diff --git a/lib/Timetabling/SolverResult.php b/lib/Timetabling/SolverResult.php index 97475145..14366dba 100644 --- a/lib/Timetabling/SolverResult.php +++ b/lib/Timetabling/SolverResult.php @@ -35,12 +35,12 @@ final class SolverResult { /** * Constructor. * - * @param array $placements Each placed lesson with its start period and room. - * @param array $unplaced Each lesson without a place, with the hard wish that blocked it. - * @param array> $brokenWishes The wishes the placements break. - * @param array $metrics The scorer's measures. - * @param int $cost The one number the search minimised. - * @param int $iterations How many moves the search tried. + * @param array $placements Each placed lesson with its start period and room. + * @param array $unplaced Each lesson without a place, with the hard wish that blocked it. + * @param array> $brokenWishes The wishes the placements break. + * @param array $metrics The scorer's measures. + * @param int $cost The one number the search minimised. + * @param int $iterations How many moves the search tried. * * @return void */ diff --git a/lib/Timetabling/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php index 29e47500..d19dfb2d 100644 --- a/lib/Timetabling/TimetableScorer.php +++ b/lib/Timetabling/TimetableScorer.php @@ -125,6 +125,19 @@ public static function cost(array $metrics): int { + ((int)$metrics['teacherGaps'] * self::GAP_COST); }//end cost() + /** + * The same number as cost(), for callers that hold a scorer. + * + * @param array $metrics The metrics of score(). + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + */ + public function costOf(array $metrics): int { + return self::cost(metrics: $metrics); + }//end costOf() + /** * The placed lessons with the periods they take; a placement that cannot be laid out is a clash. * From 832a28c7ae762a74a37dcf3fa2b35f457806ce6e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 07:34:36 +0200 Subject: [PATCH 07/14] feat(timetable): take the current timetable into a scenario, scored like a generated one --- appinfo/routes.php | 2 + l10n/be.js | 6 +- l10n/be.json | 6 +- l10n/bg.js | 6 +- l10n/bg.json | 6 +- l10n/bs.js | 6 +- l10n/bs.json | 6 +- l10n/ca.js | 6 +- l10n/ca.json | 6 +- l10n/cs.js | 6 +- l10n/cs.json | 6 +- l10n/da.js | 6 +- l10n/da.json | 6 +- l10n/de.js | 6 +- l10n/de.json | 6 +- l10n/el.js | 6 +- l10n/el.json | 6 +- l10n/en.js | 6 +- l10n/en.json | 6 +- l10n/es.js | 6 +- l10n/es.json | 6 +- l10n/et.js | 6 +- l10n/et.json | 6 +- l10n/fi.js | 6 +- l10n/fi.json | 6 +- l10n/fr.js | 6 +- l10n/fr.json | 6 +- l10n/ga.js | 6 +- l10n/ga.json | 6 +- l10n/hr.js | 6 +- l10n/hr.json | 6 +- l10n/hu.js | 6 +- l10n/hu.json | 6 +- l10n/is.js | 6 +- l10n/is.json | 6 +- l10n/it.js | 6 +- l10n/it.json | 6 +- l10n/lb.js | 6 +- l10n/lb.json | 6 +- l10n/lt.js | 6 +- l10n/lt.json | 6 +- l10n/lv.js | 6 +- l10n/lv.json | 6 +- l10n/mk.js | 6 +- l10n/mk.json | 6 +- l10n/mt.js | 6 +- l10n/mt.json | 6 +- l10n/nb.js | 6 +- l10n/nb.json | 6 +- l10n/nl.js | 6 +- l10n/nl.json | 6 +- l10n/pl.js | 6 +- l10n/pl.json | 6 +- l10n/pt.js | 6 +- l10n/pt.json | 6 +- l10n/rm.js | 6 +- l10n/rm.json | 6 +- l10n/ro.js | 6 +- l10n/ro.json | 6 +- l10n/ru.js | 6 +- l10n/ru.json | 6 +- l10n/sk.js | 6 +- l10n/sk.json | 6 +- l10n/sl.js | 6 +- l10n/sl.json | 6 +- l10n/sq.js | 6 +- l10n/sq.json | 6 +- l10n/sr.js | 6 +- l10n/sr.json | 6 +- l10n/sv.js | 6 +- l10n/sv.json | 6 +- l10n/tr.js | 6 +- l10n/tr.json | 6 +- l10n/uk.js | 6 +- l10n/uk.json | 6 +- .../TimetableScenarioController.php | 29 ++- lib/Service/TimetableGridService.php | 25 ++ lib/Service/TimetableInputBuilder.php | 7 +- lib/Service/TimetableScenarioImporter.php | 203 +++++++++++++++ lib/Service/TimetableScenarioStore.php | 37 +++ .../changes/timetabling-generator/tasks.md | 2 +- src/components/TimetableScenarioSections.vue | 92 ++++++- src/utils/timetableScenarios.js | 26 ++ .../TimetableScenarioControllerTest.php | 56 ++++- .../Service/TimetableScenarioImporterTest.php | 236 ++++++++++++++++++ tests/vitest/timetableScenarios.spec.js | 32 ++- 86 files changed, 1105 insertions(+), 86 deletions(-) create mode 100644 lib/Service/TimetableScenarioImporter.php create mode 100644 tests/unit/Service/TimetableScenarioImporterTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 99bd88eb..3f61b44a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -57,4 +57,6 @@ ['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'], // Admins start a generator run for a timetable scenario (timetabling-generator 5.2). ['name' => 'timetableScenario#generate', 'url' => '/api/timetable/scenarios/{id}/generate', 'verb' => 'POST'], + // Admins take the current timetable into an imported scenario (timetabling-generator 6.1). + ['name' => 'timetableScenario#importCurrent', 'url' => '/api/timetable/scenarios/{id}/import', 'verb' => 'POST'], ]); diff --git a/l10n/be.js b/l10n/be.js index d5e5d888..7af566af 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Не ўдалося пачаць запуск.", "Scenarios": "Сцэнарыі", "Timetable scenarios": "Сцэнарыі раскладу", - "Scenario": "Сцэнарый" + "Scenario": "Сцэнарый", + "Take the current timetable": "Узяць бягучы расклад", + "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", + "Broken hard wishes": "Парушаныя абавязковыя пажаданні", + "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 56c7e1f4..581baabd 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Не ўдалося пачаць запуск.", "Scenarios": "Сцэнарыі", "Timetable scenarios": "Сцэнарыі раскладу", - "Scenario": "Сцэнарый" + "Scenario": "Сцэнарый", + "Take the current timetable": "Узяць бягучы расклад", + "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", + "Broken hard wishes": "Парушаныя абавязковыя пажаданні", + "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 4ce5251f..339339c0 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Изпълнението не можа да започне.", "Scenarios": "Сценарии", "Timetable scenarios": "Сценарии за разписание", - "Scenario": "Сценарий" + "Scenario": "Сценарий", + "Take the current timetable": "Вземи текущото разписание", + "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", + "Broken hard wishes": "Нарушени задължителни желания", + "Not on a period of the week grid": "Не е в час от седмичната мрежа" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index f9f62d9f..6fa000e1 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Изпълнението не можа да започне.", "Scenarios": "Сценарии", "Timetable scenarios": "Сценарии за разписание", - "Scenario": "Сценарий" + "Scenario": "Сценарий", + "Take the current timetable": "Вземи текущото разписание", + "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", + "Broken hard wishes": "Нарушени задължителни желания", + "Not on a period of the week grid": "Не е в час от седмичната мрежа" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index 381850eb..69223da8 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Pokretanje nije moguće započeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji rasporeda", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Preuzmi trenutni raspored", + "Could not take the current timetable.": "Trenutni raspored nije moguće preuzeti.", + "Broken hard wishes": "Prekršene obavezne želje", + "Not on a period of the week grid": "Nije na času sedmične mreže" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 704e09e8..8eabb998 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Pokretanje nije moguće započeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji rasporeda", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Preuzmi trenutni raspored", + "Could not take the current timetable.": "Trenutni raspored nije moguće preuzeti.", + "Broken hard wishes": "Prekršene obavezne želje", + "Not on a period of the week grid": "Nije na času sedmične mreže" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index 2f166e6c..a9c95c55 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "No s'ha pogut iniciar l'execució.", "Scenarios": "Escenaris", "Timetable scenarios": "Escenaris d'horari", - "Scenario": "Escenari" + "Scenario": "Escenari", + "Take the current timetable": "Agafa l'horari actual", + "Could not take the current timetable.": "No s'ha pogut agafar l'horari actual.", + "Broken hard wishes": "Desitjos obligatoris incomplerts", + "Not on a period of the week grid": "No és en una franja de la graella setmanal" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index b191ed4f..dc1b6c30 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "No s'ha pogut iniciar l'execució.", "Scenarios": "Escenaris", "Timetable scenarios": "Escenaris d'horari", - "Scenario": "Escenari" + "Scenario": "Escenari", + "Take the current timetable": "Agafa l'horari actual", + "Could not take the current timetable.": "No s'ha pogut agafar l'horari actual.", + "Broken hard wishes": "Desitjos obligatoris incomplerts", + "Not on a period of the week grid": "No és en una franja de la graella setmanal" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 1320cd86..0b9c3eea 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Běh se nepodařilo spustit.", "Scenarios": "Scénáře", "Timetable scenarios": "Scénáře rozvrhu", - "Scenario": "Scénář" + "Scenario": "Scénář", + "Take the current timetable": "Převzít aktuální rozvrh", + "Could not take the current timetable.": "Aktuální rozvrh se nepodařilo převzít.", + "Broken hard wishes": "Porušená závazná přání", + "Not on a period of the week grid": "Není na hodině týdenní mřížky" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index f312dcd5..c7b651ce 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Běh se nepodařilo spustit.", "Scenarios": "Scénáře", "Timetable scenarios": "Scénáře rozvrhu", - "Scenario": "Scénář" + "Scenario": "Scénář", + "Take the current timetable": "Převzít aktuální rozvrh", + "Could not take the current timetable.": "Aktuální rozvrh se nepodařilo převzít.", + "Broken hard wishes": "Porušená závazná přání", + "Not on a period of the week grid": "Není na hodině týdenní mřížky" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index a039c2c5..2b10c96b 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Kørslen kunne ikke startes.", "Scenarios": "Scenarier", "Timetable scenarios": "Skemascenarier", - "Scenario": "Scenarie" + "Scenario": "Scenarie", + "Take the current timetable": "Brug det nuværende skema", + "Could not take the current timetable.": "Det nuværende skema kunne ikke hentes.", + "Broken hard wishes": "Brudte hårde ønsker", + "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 0c9298c1..2d37a1d3 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Kørslen kunne ikke startes.", "Scenarios": "Scenarier", "Timetable scenarios": "Skemascenarier", - "Scenario": "Scenarie" + "Scenario": "Scenarie", + "Take the current timetable": "Brug det nuværende skema", + "Could not take the current timetable.": "Det nuværende skema kunne ikke hentes.", + "Broken hard wishes": "Brudte hårde ønsker", + "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index dd58905e..c76e9477 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Der Lauf konnte nicht gestartet werden.", "Scenarios": "Szenarien", "Timetable scenarios": "Stundenplanszenarien", - "Scenario": "Szenario" + "Scenario": "Szenario", + "Take the current timetable": "Aktuellen Stundenplan übernehmen", + "Could not take the current timetable.": "Der aktuelle Stundenplan konnte nicht übernommen werden.", + "Broken hard wishes": "Verletzte harte Wünsche", + "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 27544709..e981f85e 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Der Lauf konnte nicht gestartet werden.", "Scenarios": "Szenarien", "Timetable scenarios": "Stundenplanszenarien", - "Scenario": "Szenario" + "Scenario": "Szenario", + "Take the current timetable": "Aktuellen Stundenplan übernehmen", + "Could not take the current timetable.": "Der aktuelle Stundenplan konnte nicht übernommen werden.", + "Broken hard wishes": "Verletzte harte Wünsche", + "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index 2305ed50..ab16ee5b 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Δεν ήταν δυνατή η έναρξη της εκτέλεσης.", "Scenarios": "Σενάρια", "Timetable scenarios": "Σενάρια ωρολογίου προγράμματος", - "Scenario": "Σενάριο" + "Scenario": "Σενάριο", + "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", + "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", + "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", + "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index ccb40592..4cce15e6 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Δεν ήταν δυνατή η έναρξη της εκτέλεσης.", "Scenarios": "Σενάρια", "Timetable scenarios": "Σενάρια ωρολογίου προγράμματος", - "Scenario": "Σενάριο" + "Scenario": "Σενάριο", + "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", + "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", + "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", + "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 3679e9d5..4f2b282d 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1023,7 +1023,11 @@ OC.L10N.register( "Could not start the run.": "Could not start the run.", "Scenarios": "Scenarios", "Timetable scenarios": "Timetable scenarios", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Take the current timetable", + "Could not take the current timetable.": "Could not take the current timetable.", + "Broken hard wishes": "Broken hard wishes", + "Not on a period of the week grid": "Not on a period of the week grid" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index c976c906..a0712641 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1022,7 +1022,11 @@ "Could not start the run.": "Could not start the run.", "Scenarios": "Scenarios", "Timetable scenarios": "Timetable scenarios", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Take the current timetable", + "Could not take the current timetable.": "Could not take the current timetable.", + "Broken hard wishes": "Broken hard wishes", + "Not on a period of the week grid": "Not on a period of the week grid" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index 6941b4cf..034e8488 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "No se pudo iniciar la ejecución.", "Scenarios": "Escenarios", "Timetable scenarios": "Escenarios de horario", - "Scenario": "Escenario" + "Scenario": "Escenario", + "Take the current timetable": "Tomar el horario actual", + "Could not take the current timetable.": "No se pudo tomar el horario actual.", + "Broken hard wishes": "Deseos obligatorios incumplidos", + "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index ca56596b..3a25fa50 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "No se pudo iniciar la ejecución.", "Scenarios": "Escenarios", "Timetable scenarios": "Escenarios de horario", - "Scenario": "Escenario" + "Scenario": "Escenario", + "Take the current timetable": "Tomar el horario actual", + "Could not take the current timetable.": "No se pudo tomar el horario actual.", + "Broken hard wishes": "Deseos obligatorios incumplidos", + "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index 166bce92..579182ac 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Käivitust ei saanud alustada.", "Scenarios": "Stsenaariumid", "Timetable scenarios": "Tunniplaani stsenaariumid", - "Scenario": "Stsenaarium" + "Scenario": "Stsenaarium", + "Take the current timetable": "Võta praegune tunniplaan", + "Could not take the current timetable.": "Praegust tunniplaani ei õnnestunud võtta.", + "Broken hard wishes": "Rikutud kohustuslikud soovid", + "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 3024d80a..295329d5 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Käivitust ei saanud alustada.", "Scenarios": "Stsenaariumid", "Timetable scenarios": "Tunniplaani stsenaariumid", - "Scenario": "Stsenaarium" + "Scenario": "Stsenaarium", + "Take the current timetable": "Võta praegune tunniplaan", + "Could not take the current timetable.": "Praegust tunniplaani ei õnnestunud võtta.", + "Broken hard wishes": "Rikutud kohustuslikud soovid", + "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 1cffd9d1..5deb13a8 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Ajoa ei voitu aloittaa.", "Scenarios": "Skenaariot", "Timetable scenarios": "Lukujärjestysskenaariot", - "Scenario": "Skenaario" + "Scenario": "Skenaario", + "Take the current timetable": "Ota nykyinen lukujärjestys", + "Could not take the current timetable.": "Nykyistä lukujärjestystä ei voitu ottaa.", + "Broken hard wishes": "Rikotut ehdottomat toiveet", + "Not on a period of the week grid": "Ei viikkoruudukon tunnilla" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 213ff06d..6510f122 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Ajoa ei voitu aloittaa.", "Scenarios": "Skenaariot", "Timetable scenarios": "Lukujärjestysskenaariot", - "Scenario": "Skenaario" + "Scenario": "Skenaario", + "Take the current timetable": "Ota nykyinen lukujärjestys", + "Could not take the current timetable.": "Nykyistä lukujärjestystä ei voitu ottaa.", + "Broken hard wishes": "Rikotut ehdottomat toiveet", + "Not on a period of the week grid": "Ei viikkoruudukon tunnilla" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 8fe820bc..2db43dc9 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Impossible de lancer l'exécution.", "Scenarios": "Scénarios", "Timetable scenarios": "Scénarios d'emploi du temps", - "Scenario": "Scénario" + "Scenario": "Scénario", + "Take the current timetable": "Reprendre l'emploi du temps actuel", + "Could not take the current timetable.": "Impossible de reprendre l'emploi du temps actuel.", + "Broken hard wishes": "Souhaits stricts non respectés", + "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 2ac95cc1..99e2d58c 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Impossible de lancer l'exécution.", "Scenarios": "Scénarios", "Timetable scenarios": "Scénarios d'emploi du temps", - "Scenario": "Scénario" + "Scenario": "Scénario", + "Take the current timetable": "Reprendre l'emploi du temps actuel", + "Could not take the current timetable.": "Impossible de reprendre l'emploi du temps actuel.", + "Broken hard wishes": "Souhaits stricts non respectés", + "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 09b45a11..27a95e3a 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Níorbh fhéidir an rith a thosú.", "Scenarios": "Cásanna", "Timetable scenarios": "Cásanna an chláir ama", - "Scenario": "Cás" + "Scenario": "Cás", + "Take the current timetable": "Tóg an clár ama reatha", + "Could not take the current timetable.": "Níorbh fhéidir an clár ama reatha a thógáil.", + "Broken hard wishes": "Mianta éigeantacha briste", + "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index b54ad526..8f4ff534 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Níorbh fhéidir an rith a thosú.", "Scenarios": "Cásanna", "Timetable scenarios": "Cásanna an chláir ama", - "Scenario": "Cás" + "Scenario": "Cás", + "Take the current timetable": "Tóg an clár ama reatha", + "Could not take the current timetable.": "Níorbh fhéidir an clár ama reatha a thógáil.", + "Broken hard wishes": "Mianta éigeantacha briste", + "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index a22d7d9b..bc5c723c 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Pokretanje nije moguće započeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji rasporeda", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Preuzmi trenutačni raspored", + "Could not take the current timetable.": "Trenutačni raspored nije moguće preuzeti.", + "Broken hard wishes": "Prekršene obvezne želje", + "Not on a period of the week grid": "Nije na satu tjedne mreže" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index 803d1680..d5f7c5e6 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Pokretanje nije moguće započeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji rasporeda", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Preuzmi trenutačni raspored", + "Could not take the current timetable.": "Trenutačni raspored nije moguće preuzeti.", + "Broken hard wishes": "Prekršene obvezne želje", + "Not on a period of the week grid": "Nije na satu tjedne mreže" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 34b24492..366387cc 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "A futtatás nem indítható.", "Scenarios": "Forgatókönyvek", "Timetable scenarios": "Órarendi forgatókönyvek", - "Scenario": "Forgatókönyv" + "Scenario": "Forgatókönyv", + "Take the current timetable": "Jelenlegi órarend átvétele", + "Could not take the current timetable.": "Nem sikerült átvenni a jelenlegi órarendet.", + "Broken hard wishes": "Megszegett kötelező kívánságok", + "Not on a period of the week grid": "Nem a heti rács egy óráján van" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 4eb3612f..6efb7953 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "A futtatás nem indítható.", "Scenarios": "Forgatókönyvek", "Timetable scenarios": "Órarendi forgatókönyvek", - "Scenario": "Forgatókönyv" + "Scenario": "Forgatókönyv", + "Take the current timetable": "Jelenlegi órarend átvétele", + "Could not take the current timetable.": "Nem sikerült átvenni a jelenlegi órarendet.", + "Broken hard wishes": "Megszegett kötelező kívánságok", + "Not on a period of the week grid": "Nem a heti rács egy óráján van" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 45bb1253..8ffb6016 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Ekki tókst að ræsa keyrsluna.", "Scenarios": "Sviðsmyndir", "Timetable scenarios": "Sviðsmyndir stundaskrár", - "Scenario": "Sviðsmynd" + "Scenario": "Sviðsmynd", + "Take the current timetable": "Taka núverandi stundaskrá", + "Could not take the current timetable.": "Ekki tókst að taka núverandi stundaskrá.", + "Broken hard wishes": "Brotnar skilyrðisóskir", + "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 156f3c8d..3cafacd9 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Ekki tókst að ræsa keyrsluna.", "Scenarios": "Sviðsmyndir", "Timetable scenarios": "Sviðsmyndir stundaskrár", - "Scenario": "Sviðsmynd" + "Scenario": "Sviðsmynd", + "Take the current timetable": "Taka núverandi stundaskrá", + "Could not take the current timetable.": "Ekki tókst að taka núverandi stundaskrá.", + "Broken hard wishes": "Brotnar skilyrðisóskir", + "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index ea59d56c..5a64d509 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Impossibile avviare l'esecuzione.", "Scenarios": "Scenari", "Timetable scenarios": "Scenari di orario", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Prendi l'orario attuale", + "Could not take the current timetable.": "Impossibile prendere l'orario attuale.", + "Broken hard wishes": "Desideri vincolanti non rispettati", + "Not on a period of the week grid": "Non su un'ora della griglia settimanale" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 440420d3..01efbd7c 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Impossibile avviare l'esecuzione.", "Scenarios": "Scenari", "Timetable scenarios": "Scenari di orario", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Prendi l'orario attuale", + "Could not take the current timetable.": "Impossibile prendere l'orario attuale.", + "Broken hard wishes": "Desideri vincolanti non rispettati", + "Not on a period of the week grid": "Non su un'ora della griglia settimanale" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 393d319b..9229420a 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "De Laf konnt net gestart ginn.", "Scenarios": "Szenarien", "Timetable scenarios": "Stonneplangszenarien", - "Scenario": "Szenario" + "Scenario": "Szenario", + "Take the current timetable": "Aktuelle Stonneplang iwwerhuelen", + "Could not take the current timetable.": "Den aktuelle Stonneplang konnt net iwwerholl ginn.", + "Broken hard wishes": "Verletzt haart Wënsch", + "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index c3ea58de..593f72d5 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "De Laf konnt net gestart ginn.", "Scenarios": "Szenarien", "Timetable scenarios": "Stonneplangszenarien", - "Scenario": "Szenario" + "Scenario": "Szenario", + "Take the current timetable": "Aktuelle Stonneplang iwwerhuelen", + "Could not take the current timetable.": "Den aktuelle Stonneplang konnt net iwwerholl ginn.", + "Broken hard wishes": "Verletzt haart Wënsch", + "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index ad25e2dd..8236b964 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Vykdymo pradėti nepavyko.", "Scenarios": "Scenarijai", "Timetable scenarios": "Tvarkaraščio scenarijai", - "Scenario": "Scenarijus" + "Scenario": "Scenarijus", + "Take the current timetable": "Perimti dabartinį tvarkaraštį", + "Could not take the current timetable.": "Nepavyko perimti dabartinio tvarkaraščio.", + "Broken hard wishes": "Pažeisti privalomi pageidavimai", + "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index 3035ffcb..d598014a 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Vykdymo pradėti nepavyko.", "Scenarios": "Scenarijai", "Timetable scenarios": "Tvarkaraščio scenarijai", - "Scenario": "Scenarijus" + "Scenario": "Scenarijus", + "Take the current timetable": "Perimti dabartinį tvarkaraštį", + "Could not take the current timetable.": "Nepavyko perimti dabartinio tvarkaraščio.", + "Broken hard wishes": "Pažeisti privalomi pageidavimai", + "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 4675c34d..da07718a 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Palaišanu neizdevās sākt.", "Scenarios": "Scenāriji", "Timetable scenarios": "Stundu saraksta scenāriji", - "Scenario": "Scenārijs" + "Scenario": "Scenārijs", + "Take the current timetable": "Pārņemt pašreizējo stundu sarakstu", + "Could not take the current timetable.": "Neizdevās pārņemt pašreizējo stundu sarakstu.", + "Broken hard wishes": "Pārkāptās obligātās vēlmes", + "Not on a period of the week grid": "Nav nedēļas režģa stundā" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 74783f55..10fee2e3 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Palaišanu neizdevās sākt.", "Scenarios": "Scenāriji", "Timetable scenarios": "Stundu saraksta scenāriji", - "Scenario": "Scenārijs" + "Scenario": "Scenārijs", + "Take the current timetable": "Pārņemt pašreizējo stundu sarakstu", + "Could not take the current timetable.": "Neizdevās pārņemt pašreizējo stundu sarakstu.", + "Broken hard wishes": "Pārkāptās obligātās vēlmes", + "Not on a period of the week grid": "Nav nedēļas režģa stundā" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 8f1e6ec4..3e2b44c0 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Извршувањето не може да започне.", "Scenarios": "Сценарија", "Timetable scenarios": "Сценарија за распоредот", - "Scenario": "Сценарио" + "Scenario": "Сценарио", + "Take the current timetable": "Преземи го тековниот распоред", + "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", + "Broken hard wishes": "Прекршени задолжителни желби", + "Not on a period of the week grid": "Не е на час од неделната мрежа" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 7d3259db..ef776340 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Извршувањето не може да започне.", "Scenarios": "Сценарија", "Timetable scenarios": "Сценарија за распоредот", - "Scenario": "Сценарио" + "Scenario": "Сценарио", + "Take the current timetable": "Преземи го тековниот распоред", + "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", + "Broken hard wishes": "Прекршени задолжителни желби", + "Not on a period of the week grid": "Не е на час од неделната мрежа" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index 10b83ab6..76d00545 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Ma setax jibda t-tħaddim.", "Scenarios": "Xenarji", "Timetable scenarios": "Xenarji tal-orarju", - "Scenario": "Xenarju" + "Scenario": "Xenarju", + "Take the current timetable": "Ħu l-orarju attwali", + "Could not take the current timetable.": "Ma setax jittieħed l-orarju attwali.", + "Broken hard wishes": "Xewqat obbligatorji miksura", + "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 52573b2d..711d6dc6 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Ma setax jibda t-tħaddim.", "Scenarios": "Xenarji", "Timetable scenarios": "Xenarji tal-orarju", - "Scenario": "Xenarju" + "Scenario": "Xenarju", + "Take the current timetable": "Ħu l-orarju attwali", + "Could not take the current timetable.": "Ma setax jittieħed l-orarju attwali.", + "Broken hard wishes": "Xewqat obbligatorji miksura", + "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index e589b7f7..e087cae2 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Kunne ikke starte kjøringen.", "Scenarios": "Scenarier", "Timetable scenarios": "Timeplanscenarier", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Bruk den gjeldende timeplanen", + "Could not take the current timetable.": "Kunne ikke hente den gjeldende timeplanen.", + "Broken hard wishes": "Brutte harde ønsker", + "Not on a period of the week grid": "Ikke på en time i ukerutenettet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 09528a88..823a9ce6 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Kunne ikke starte kjøringen.", "Scenarios": "Scenarier", "Timetable scenarios": "Timeplanscenarier", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Bruk den gjeldende timeplanen", + "Could not take the current timetable.": "Kunne ikke hente den gjeldende timeplanen.", + "Broken hard wishes": "Brutte harde ønsker", + "Not on a period of the week grid": "Ikke på en time i ukerutenettet" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 653aaa36..688e1b27 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1058,7 +1058,11 @@ OC.L10N.register( "Could not start the run.": "Kon de run niet starten.", "Scenarios": "Scenario's", "Timetable scenarios": "Roosterscenario's", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Huidig rooster overnemen", + "Could not take the current timetable.": "Kon het huidige rooster niet overnemen.", + "Broken hard wishes": "Geschonden harde wensen", + "Not on a period of the week grid": "Niet op een lesuur van het weekrooster" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 00faa044..f8476275 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1057,7 +1057,11 @@ "Could not start the run.": "Kon de run niet starten.", "Scenarios": "Scenario's", "Timetable scenarios": "Roosterscenario's", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Huidig rooster overnemen", + "Could not take the current timetable.": "Kon het huidige rooster niet overnemen.", + "Broken hard wishes": "Geschonden harde wensen", + "Not on a period of the week grid": "Niet op een lesuur van het weekrooster" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 75f9e257..8d405cd1 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Nie udało się uruchomić przebiegu.", "Scenarios": "Scenariusze", "Timetable scenarios": "Scenariusze planu lekcji", - "Scenario": "Scenariusz" + "Scenario": "Scenariusz", + "Take the current timetable": "Przejmij obecny plan lekcji", + "Could not take the current timetable.": "Nie udało się przejąć obecnego planu lekcji.", + "Broken hard wishes": "Naruszone twarde życzenia", + "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index f7e08281..94445d51 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Nie udało się uruchomić przebiegu.", "Scenarios": "Scenariusze", "Timetable scenarios": "Scenariusze planu lekcji", - "Scenario": "Scenariusz" + "Scenario": "Scenariusz", + "Take the current timetable": "Przejmij obecny plan lekcji", + "Could not take the current timetable.": "Nie udało się przejąć obecnego planu lekcji.", + "Broken hard wishes": "Naruszone twarde życzenia", + "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index cb8ac8bf..819aa0e5 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Não foi possível iniciar a execução.", "Scenarios": "Cenários", "Timetable scenarios": "Cenários de horário", - "Scenario": "Cenário" + "Scenario": "Cenário", + "Take the current timetable": "Usar o horário atual", + "Could not take the current timetable.": "Não foi possível usar o horário atual.", + "Broken hard wishes": "Desejos obrigatórios não cumpridos", + "Not on a period of the week grid": "Não está num tempo da grelha semanal" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 03a75d71..71a7f95f 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Não foi possível iniciar a execução.", "Scenarios": "Cenários", "Timetable scenarios": "Cenários de horário", - "Scenario": "Cenário" + "Scenario": "Cenário", + "Take the current timetable": "Usar o horário atual", + "Could not take the current timetable.": "Não foi possível usar o horário atual.", + "Broken hard wishes": "Desejos obrigatórios não cumpridos", + "Not on a period of the week grid": "Não está num tempo da grelha semanal" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 554a534b..f5a1ea1d 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "La lantschada n'ha betg pudì vegnir cumenzada.", "Scenarios": "Scenaris", "Timetable scenarios": "Scenaris dal plan d'uras", - "Scenario": "Scenari" + "Scenario": "Scenari", + "Take the current timetable": "Surpigliar l'urari actual", + "Could not take the current timetable.": "Impussibel da surpigliar l'urari actual.", + "Broken hard wishes": "Giavischs obligatorics violads", + "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 9ff41bf9..f706c75c 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "La lantschada n'ha betg pudì vegnir cumenzada.", "Scenarios": "Scenaris", "Timetable scenarios": "Scenaris dal plan d'uras", - "Scenario": "Scenari" + "Scenario": "Scenari", + "Take the current timetable": "Surpigliar l'urari actual", + "Could not take the current timetable.": "Impussibel da surpigliar l'urari actual.", + "Broken hard wishes": "Giavischs obligatorics violads", + "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index b6631d2e..73034a4b 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Rularea nu a putut porni.", "Scenarios": "Scenarii", "Timetable scenarios": "Scenarii de orar", - "Scenario": "Scenariu" + "Scenario": "Scenariu", + "Take the current timetable": "Preia orarul actual", + "Could not take the current timetable.": "Nu s-a putut prelua orarul actual.", + "Broken hard wishes": "Dorințe obligatorii încălcate", + "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index 40513414..e16aa689 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Rularea nu a putut porni.", "Scenarios": "Scenarii", "Timetable scenarios": "Scenarii de orar", - "Scenario": "Scenariu" + "Scenario": "Scenariu", + "Take the current timetable": "Preia orarul actual", + "Could not take the current timetable.": "Nu s-a putut prelua orarul actual.", + "Broken hard wishes": "Dorințe obligatorii încălcate", + "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index 53c172bf..135d18f8 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Не удалось начать запуск.", "Scenarios": "Сценарии", "Timetable scenarios": "Сценарии расписания", - "Scenario": "Сценарий" + "Scenario": "Сценарий", + "Take the current timetable": "Взять текущее расписание", + "Could not take the current timetable.": "Не удалось взять текущее расписание.", + "Broken hard wishes": "Нарушенные обязательные пожелания", + "Not on a period of the week grid": "Не на уроке недельной сетки" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 7b3c2609..861a0245 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Не удалось начать запуск.", "Scenarios": "Сценарии", "Timetable scenarios": "Сценарии расписания", - "Scenario": "Сценарий" + "Scenario": "Сценарий", + "Take the current timetable": "Взять текущее расписание", + "Could not take the current timetable.": "Не удалось взять текущее расписание.", + "Broken hard wishes": "Нарушенные обязательные пожелания", + "Not on a period of the week grid": "Не на уроке недельной сетки" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index 8a9a73bc..79bfa351 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Beh sa nepodarilo spustiť.", "Scenarios": "Scenáre", "Timetable scenarios": "Scenáre rozvrhu", - "Scenario": "Scenár" + "Scenario": "Scenár", + "Take the current timetable": "Prevziať aktuálny rozvrh", + "Could not take the current timetable.": "Aktuálny rozvrh sa nepodarilo prevziať.", + "Broken hard wishes": "Porušené záväzné priania", + "Not on a period of the week grid": "Nie je na hodine týždennej mriežky" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 0ee3c056..11b2b153 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Beh sa nepodarilo spustiť.", "Scenarios": "Scenáre", "Timetable scenarios": "Scenáre rozvrhu", - "Scenario": "Scenár" + "Scenario": "Scenár", + "Take the current timetable": "Prevziať aktuálny rozvrh", + "Could not take the current timetable.": "Aktuálny rozvrh sa nepodarilo prevziať.", + "Broken hard wishes": "Porušené záväzné priania", + "Not on a period of the week grid": "Nie je na hodine týždennej mriežky" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index 31409b3c..89f7809b 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Zagona ni bilo mogoče začeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji urnika", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Prevzemi trenutni urnik", + "Could not take the current timetable.": "Trenutnega urnika ni bilo mogoče prevzeti.", + "Broken hard wishes": "Kršene obvezne želje", + "Not on a period of the week grid": "Ni na uri tedenske mreže" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index cf354b1a..5fba1890 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Zagona ni bilo mogoče začeti.", "Scenarios": "Scenariji", "Timetable scenarios": "Scenariji urnika", - "Scenario": "Scenarij" + "Scenario": "Scenarij", + "Take the current timetable": "Prevzemi trenutni urnik", + "Could not take the current timetable.": "Trenutnega urnika ni bilo mogoče prevzeti.", + "Broken hard wishes": "Kršene obvezne želje", + "Not on a period of the week grid": "Ni na uri tedenske mreže" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 690e2a89..88059549 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Ekzekutimi nuk u nis dot.", "Scenarios": "Skenarë", "Timetable scenarios": "Skenarë orari", - "Scenario": "Skenar" + "Scenario": "Skenar", + "Take the current timetable": "Merr orarin aktual", + "Could not take the current timetable.": "Orari aktual nuk u mor dot.", + "Broken hard wishes": "Dëshira të detyrueshme të shkelura", + "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 20e293f5..c57a4a2e 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Ekzekutimi nuk u nis dot.", "Scenarios": "Skenarë", "Timetable scenarios": "Skenarë orari", - "Scenario": "Skenar" + "Scenario": "Skenar", + "Take the current timetable": "Merr orarin aktual", + "Could not take the current timetable.": "Orari aktual nuk u mor dot.", + "Broken hard wishes": "Dëshira të detyrueshme të shkelura", + "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index 58890fe1..428bd739 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Покретање није могуће започети.", "Scenarios": "Сценарији", "Timetable scenarios": "Сценарији распореда", - "Scenario": "Сценарио" + "Scenario": "Сценарио", + "Take the current timetable": "Преузми тренутни распоред", + "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", + "Broken hard wishes": "Прекршене обавезне жеље", + "Not on a period of the week grid": "Није на часу недељне мреже" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 6576f470..b891f9cd 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Покретање није могуће започети.", "Scenarios": "Сценарији", "Timetable scenarios": "Сценарији распореда", - "Scenario": "Сценарио" + "Scenario": "Сценарио", + "Take the current timetable": "Преузми тренутни распоред", + "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", + "Broken hard wishes": "Прекршене обавезне жеље", + "Not on a period of the week grid": "Није на часу недељне мреже" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 95b6971a..7d71f3bb 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Körningen kunde inte startas.", "Scenarios": "Scenarier", "Timetable scenarios": "Schemascenarier", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Använd det nuvarande schemat", + "Could not take the current timetable.": "Det gick inte att hämta det nuvarande schemat.", + "Broken hard wishes": "Brutna hårda önskemål", + "Not on a period of the week grid": "Inte på en lektion i veckoschemat" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index 42d4a236..73ecdce0 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Körningen kunde inte startas.", "Scenarios": "Scenarier", "Timetable scenarios": "Schemascenarier", - "Scenario": "Scenario" + "Scenario": "Scenario", + "Take the current timetable": "Använd det nuvarande schemat", + "Could not take the current timetable.": "Det gick inte att hämta det nuvarande schemat.", + "Broken hard wishes": "Brutna hårda önskemål", + "Not on a period of the week grid": "Inte på en lektion i veckoschemat" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index e7fb2cd1..3914fe95 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Çalışma başlatılamadı.", "Scenarios": "Senaryolar", "Timetable scenarios": "Ders programı senaryoları", - "Scenario": "Senaryo" + "Scenario": "Senaryo", + "Take the current timetable": "Geçerli ders programını al", + "Could not take the current timetable.": "Geçerli ders programı alınamadı.", + "Broken hard wishes": "Çiğnenen zorunlu istekler", + "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 0f3382b1..eac4fcc9 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Çalışma başlatılamadı.", "Scenarios": "Senaryolar", "Timetable scenarios": "Ders programı senaryoları", - "Scenario": "Senaryo" + "Scenario": "Senaryo", + "Take the current timetable": "Geçerli ders programını al", + "Could not take the current timetable.": "Geçerli ders programı alınamadı.", + "Broken hard wishes": "Çiğnenen zorunlu istekler", + "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index ce906efb..3aece6fd 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1045,7 +1045,11 @@ OC.L10N.register( "Could not start the run.": "Не вдалося почати запуск.", "Scenarios": "Сценарії", "Timetable scenarios": "Сценарії розкладу", - "Scenario": "Сценарій" + "Scenario": "Сценарій", + "Take the current timetable": "Взяти поточний розклад", + "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", + "Broken hard wishes": "Порушені обов'язкові побажання", + "Not on a period of the week grid": "Не на уроці тижневої сітки" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 99922297..80981c76 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1044,7 +1044,11 @@ "Could not start the run.": "Не вдалося почати запуск.", "Scenarios": "Сценарії", "Timetable scenarios": "Сценарії розкладу", - "Scenario": "Сценарій" + "Scenario": "Сценарій", + "Take the current timetable": "Взяти поточний розклад", + "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", + "Broken hard wishes": "Порушені обов'язкові побажання", + "Not on a period of the week grid": "Не на уроці тижневої сітки" }, "plurals": {} } diff --git a/lib/Controller/TimetableScenarioController.php b/lib/Controller/TimetableScenarioController.php index 90ec8748..7d714f41 100644 --- a/lib/Controller/TimetableScenarioController.php +++ b/lib/Controller/TimetableScenarioController.php @@ -29,6 +29,7 @@ use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; use OCA\Planninq\Service\SettingsService; use OCA\Planninq\Service\TimetableGenerationService; +use OCA\Planninq\Service\TimetableScenarioImporter; use OCA\Planninq\Settings\AdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -38,9 +39,10 @@ use OCP\IRequest; /** - * The generate endpoint of timetable scenarios. + * The generate and import endpoints of timetable scenarios. * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 */ class TimetableScenarioController extends Controller { @@ -51,6 +53,7 @@ class TimetableScenarioController extends Controller { * @param TimetableGenerationService $generation Queues the run. * @param IJobList $jobList Runs it in the background. * @param SettingsService $settingsService Tells whether the user is an admin. + * @param TimetableScenarioImporter $importer Takes the current timetable into a scenario. * * @return void */ @@ -59,6 +62,7 @@ public function __construct( private readonly TimetableGenerationService $generation, private readonly IJobList $jobList, private readonly SettingsService $settingsService, + private readonly TimetableScenarioImporter $importer, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -88,4 +92,27 @@ public function generate(string $id): JSONResponse { return new JSONResponse(['id' => $id, 'status' => $scenario['status'], 'lessons' => count($scenario['input']['lessons'])], Http::STATUS_ACCEPTED); }//end generate() + /** + * Fill an imported scenario from the scheduled lessons of its week, scored like a generated one. + * + * @param string $id The scenario id. + * + * @return JSONResponse + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function importCurrent(string $id): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can take the current timetable into a scenario.'], Http::STATUS_FORBIDDEN); + } + + try { + $scenario = $this->importer->importInto(id: $id); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(['id' => $id, 'status' => $scenario['status'], 'metrics' => $scenario['metrics']]); + }//end importCurrent() }//end class diff --git a/lib/Service/TimetableGridService.php b/lib/Service/TimetableGridService.php index 0e68b5a4..d3c33c44 100644 --- a/lib/Service/TimetableGridService.php +++ b/lib/Service/TimetableGridService.php @@ -153,6 +153,31 @@ public function periodKeys(): array { return $keys; }//end periodKeys() + /** + * Every period of the stored grid with its key, day, number, start and end, day by day. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + public function periodTimes(): array { + $grid = (array)json_decode(($this->normaliseGrid(raw: $this->settings()[self::GRID_KEY]) ?? self::DEFAULT_GRID), true); + $times = []; + foreach ((array)$grid['days'] as $day) { + foreach (array_values((array)$grid['periods']) as $index => $period) { + $times[] = [ + 'key' => $day.'-'.($index + 1), + 'day' => (string)$day, + 'number' => ($index + 1), + 'start' => (string)$period['start'], + 'end' => (string)$period['end'], + ]; + } + } + + return $times; + }//end periodTimes() + /** * A submitted grid as it is stored, or null to refuse it. * diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php index 02c6d4a2..8c77b780 100644 --- a/lib/Service/TimetableInputBuilder.php +++ b/lib/Service/TimetableInputBuilder.php @@ -96,7 +96,7 @@ public function __construct( */ public function build(string $academicYear, array $wishes): SolverInput { $periods = $this->grid->periodKeys(); - $wishes = array_map(fn (array $wish): array => $this->wish(wish: $wish), array_values($wishes)); + $wishes = array_map(fn (array $wish): array => $this->solverWish(wish: $wish), array_values($wishes)); $event = new TimetableActivitiesQueryEvent(academicYear: $academicYear); $this->dispatcher->dispatchTyped($event); @@ -171,8 +171,9 @@ private function lessons(array $activities): array { * @return array * * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 */ - private function wish(array $wish): array { + public function solverWish(array $wish): array { $out = ['id' => (string)($wish['id'] ?? ($wish['@self']['id'] ?? ''))]; foreach (self::WISH_FIELDS as $field) { if (array_key_exists($field, $wish) === true) { @@ -181,7 +182,7 @@ private function wish(array $wish): array { } return $out; - }//end wish() + }//end solverWish() /** * A stored sheet, or an empty list. diff --git a/lib/Service/TimetableScenarioImporter.php b/lib/Service/TimetableScenarioImporter.php new file mode 100644 index 00000000..26aa9190 --- /dev/null +++ b/lib/Service/TimetableScenarioImporter.php @@ -0,0 +1,203 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use DateTimeImmutable; +use InvalidArgumentException; +use OCA\Planninq\Timetabling\SolverInput; +use OCA\Planninq\Timetabling\TimetableScorer; +use Throwable; + +/** + * Snapshot the current timetable into a scenario. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ +class TimetableScenarioImporter { + + /** + * Constructor. + * + * @param TimetableScenarioStore $store Reads the lessons and wishes, writes the scenario. + * @param TimetableGridService $grid The week grid. + * @param TimetableInputBuilder $inputBuilder Shapes the wishes as the solver reads them. + * @param TimetableScorer $scorer Scores the week. + * + * @return void + */ + public function __construct( + private readonly TimetableScenarioStore $store, + private readonly TimetableGridService $grid, + private readonly TimetableInputBuilder $inputBuilder, + private readonly TimetableScorer $scorer=new TimetableScorer(), + ) { + }//end __construct() + + /** + * Fill an imported scenario from the scheduled lessons of its first week, and store it as done. + * + * @param string $id The scenario id. + * + * @return array The stored scenario. + * + * @throws InvalidArgumentException When the scenario does not exist, is not imported, or its week has no lessons. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + public function importInto(string $id): array { + $scenario = $this->store->load(id: $id); + if ($scenario === null || ($scenario['source'] ?? '') !== 'imported' || ($scenario['status'] ?? '') === 'published') { + throw new InvalidArgumentException('Only an imported scenario that is not published can take the current timetable.'); + } + + $monday = $this->monday(date: (string)($scenario['weekOf'] ?? '')); + $sessions = $this->store->scheduledLessons( + from: $monday->format(DATE_ATOM), + to: $monday->modify('+7 days')->modify('-1 second')->format(DATE_ATOM) + ); + if ($sessions === []) { + throw new InvalidArgumentException('The week of this scenario has no scheduled lessons.'); + } + + $week = $this->weekOf(sessions: $sessions); + $wishes = array_map(fn (array $wish): array => $this->inputBuilder->solverWish(wish: $wish), $this->store->wishes()); + $input = new SolverInput(periods: $this->grid->periodKeys(), rooms: $week['rooms'], lessons: $week['lessons'], wishes: $wishes, source: 'imported'); + $score = $this->scorer->score(input: $input, placements: $week['placements']); + + $scenario = array_merge( + $scenario, + [ + 'status' => 'done', + 'input' => $input->toArray(), + 'placements' => $week['placements'], + 'unplaced' => $week['unplaced'], + 'brokenWishes' => $score['brokenWishes'], + 'metrics' => array_merge($score['metrics'], ['cost' => $this->scorer->costOf(metrics: $score['metrics'])]), + 'reason' => '', + ] + ); + $this->store->save(id: $id, data: $scenario); + + return $scenario; + }//end importInto() + + /** + * The lessons, rooms and placements of one week of stored lessons; a lesson whose times + * are not periods of the grid is kept as unplaced with the reason `offGrid`. + * + * @param array> $sessions The stored lessons. + * + * @return array{lessons:array>,rooms:array>,placements:array>,unplaced:array>} + */ + private function weekOf(array $sessions): array { + $periods = $this->grid->periodTimes(); + $counts = []; + $week = ['lessons' => [], 'rooms' => [], 'placements' => [], 'unplaced' => []]; + foreach ($sessions as $session) { + $group = (string)($session['groupReference'] ?? ($session['cohortId'] ?? '')); + $subject = (string)($session['subject'] ?? ''); + $activity = $group.':'.$subject; + $counts[$activity] = (($counts[$activity] ?? 0) + 1); + $slot = $this->slotOf(session: $session, periods: $periods); + $room = (string)($session['roomReference'] ?? ''); + $week['lessons'][] = [ + 'key' => $activity.':'.$counts[$activity], + 'activity' => $activity, + 'group' => $group, + 'subject' => $subject, + 'teacher' => (string)($session['teacherUserId'] ?? ($session['teacherReference'] ?? '')), + 'roomType' => '', + 'length' => ($slot['length'] ?? 1), + ]; + $week['rooms'][$room] = ['reference' => $room, 'capacity' => 0, 'type' => '']; + if ($slot === null) { + $week['unplaced'][] = ['lesson' => $activity.':'.$counts[$activity], 'wish' => null, 'reason' => 'offGrid']; + continue; + } + + $week['placements'][] = ['lesson' => $activity.':'.$counts[$activity], 'period' => $slot['period'], 'room' => $room]; + }//end foreach + + $week['rooms'] = array_values($week['rooms']); + return $week; + }//end weekOf() + + /** + * The period key and length of a stored lesson, or null when it does not start and end on period times. + * + * @param array $session The stored lesson. + * @param array $periods The grid's periods. + * + * @return array{period:string,length:int}|null + */ + private function slotOf(array $session, array $periods): ?array { + try { + $starts = new DateTimeImmutable((string)($session['startsAt'] ?? '')); + $ends = new DateTimeImmutable((string)($session['endsAt'] ?? '')); + } catch (Throwable) { + return null; + } + + $day = strtolower($starts->format('D')); + $first = null; + $length = 0; + foreach ($periods as $period) { + if ($period['day'] !== substr($day, 0, 3) || $period['start'] < $starts->format('H:i') || $period['end'] > $ends->format('H:i')) { + continue; + } + + $first = ($first ?? $period); + $length++; + } + + if ($first === null || $first['start'] !== $starts->format('H:i')) { + return null; + } + + return ['period' => $first['day'].'-'.$first['number'], 'length' => $length]; + }//end slotOf() + + /** + * The Monday of a date's week, at midnight. + * + * @param string $date A date. + * + * @return DateTimeImmutable + * + * @throws InvalidArgumentException When it is not a date. + */ + private function monday(string $date): DateTimeImmutable { + try { + $day = new DateTimeImmutable($date); + } catch (Throwable) { + throw new InvalidArgumentException('The scenario has no week to take the lessons from.'); + } + + return $day->modify('monday this week')->setTime(0, 0); + }//end monday() +}//end class diff --git a/lib/Service/TimetableScenarioStore.php b/lib/Service/TimetableScenarioStore.php index 260587f1..100b1740 100644 --- a/lib/Service/TimetableScenarioStore.php +++ b/lib/Service/TimetableScenarioStore.php @@ -54,6 +54,11 @@ class TimetableScenarioStore { */ public const WISH = 'timetableWish'; + /** + * The lesson schema slug. + */ + public const SESSION = 'timetableSession'; + /** * The most wishes one run reads. */ @@ -140,6 +145,38 @@ public function wishes(): array { return $wishes; }//end wishes() + /** + * The scheduled lessons that start in a window, as stored. + * + * @param string $from The first moment, ISO 8601. + * @param string $to The last moment, ISO 8601. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + public function scheduledLessons(string $from, string $to): array { + $results = $this->objectService()->searchObjectsBySlug( + registerSlug: self::REGISTER, + schemaSlug: self::SESSION, + filters: ['status' => 'scheduled', 'startsAt' => ['gte' => $from, 'lte' => $to], '_limit' => TimetableSessionQuery::MAX_LIMIT], + _rbac: false + ); + $first = strtotime($from); + $last = strtotime($to); + $lessons = []; + foreach ((new TimetableSessionRows())->listOf(results: $results) as $row) { + $data = (array)$this->dataOf(object: $row); + $starts = strtotime((string)($data['startsAt'] ?? '')); + // Re-checked here, so the snapshot never depends on OpenRegister applying every filter. + if (($data['status'] ?? '') === 'scheduled' && $starts !== false && $starts >= $first && $starts <= $last) { + $lessons[] = $data; + } + } + + return $lessons; + }//end scheduledLessons() + /** * An object's data with its id, from an ObjectEntity or an array; null for anything else. * diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 14aa94b5..64f16740 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -31,7 +31,7 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 6. Imported scenarios -- [ ] 6.1 "Make a scenario from the current timetable": snapshot the `timetableSession` rows of a window into a scenario with `source` `imported`, scored with the same scorer. Verify: PHPUnit: an imported timetable that breaks a hard wish shows that breach in `metrics`. +- [x] 6.1 "Make a scenario from the current timetable": snapshot the `timetableSession` rows of a window into a scenario with `source` `imported`, scored with the same scorer. Verify: PHPUnit: an imported timetable that breaks a hard wish shows that breach in `metrics`. Built (section 6 PR): `TimetableScenarioImporter` takes the scheduled lessons of the scenario's week (`weekOf`) as a week pattern (a lesson off the grid's period times is unplaced with reason `offGrid`), `POST /api/timetable/scenarios/{id}/import` (admin), button "Take the current timetable" and a "Broken hard wishes" list on the scenario page; PHPUnit `TimetableScenarioImporterTest`, `TimetableScenarioControllerTest`, vitest `timetableScenarios.spec.js`. ## 7. Compare diff --git a/src/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue index fa0c1b91..3e55f6df 100644 --- a/src/components/TimetableScenarioSections.vue +++ b/src/components/TimetableScenarioSections.vue @@ -30,6 +30,14 @@ @click="generate"> {{ scenario.status === 'done' || scenario.status === 'failed' ? t('planninq', 'Generate again') : t('planninq', 'Generate') }} + + {{ t('planninq', 'Take the current timetable') }} + @@ -55,7 +63,29 @@ {{ row.lesson }} - {{ row.wish ? describe(row.wish) : t('planninq', 'No free period and room') }} + {{ blockedBy(row) }} + + + + + +
+

{{ t('planninq', 'Broken hard wishes') }}

+ + + + + + + + + + +
+ {{ t('planninq', 'Wish') }} + + {{ t('planninq', 'Lessons') }} +
{{ describe(row.wish) }}{{ row.lessons.join(', ') }}
@@ -100,15 +130,18 @@ * sections slot): the run's status and progress, the Generate button for * admins, the lessons without a place with the hard wish that blocked them, * and the soft wishes the run broke. While a run is queued or running the - * page reads the scenario again every five seconds. + * page reads the scenario again every five seconds. On an imported scenario + * an admin takes the current timetable instead, and its broken hard wishes + * are listed as well. * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 */ import { buildHeaders } from '@conduction/nextcloud-vue' import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' import { NcButton, NcLoadingIcon, NcProgressBar } from '@nextcloud/vue' -import { brokenSoftRows, isRunning, progressPercent, unplacedRows } from '../utils/timetableScenarios.js' +import { brokenHardRows, brokenSoftRows, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../utils/timetableScenarios.js' const POLL_MS = 5000 @@ -194,6 +227,26 @@ export default { && this.scenario?.status !== 'published' }, + /** + * Whether this user may take the current timetable into this scenario. + * + * @return {boolean} + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + canImport() { + return getCurrentUser()?.isAdmin === true && canTakeCurrentTimetable(this.scenario) + }, + + /** + * The broken hard wishes (an imported timetable only). + * + * @return {Array} + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + brokenHard() { + return brokenHardRows(this.scenario) + }, + /** * The unplaced lessons. * @@ -275,6 +328,39 @@ export default { await this.load() }, + /** + * Take the scheduled lessons of the scenario's week into it. + * + * @return {Promise} + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + async takeCurrent() { + this.starting = true + this.startError = '' + const response = await fetch(generateUrl(`/apps/planninq/api/timetable/scenarios/${this.id}/import`), { method: 'POST', headers: buildHeaders() }).catch(() => null) + this.starting = false + if (!response?.ok) { + const body = await response?.json().catch(() => ({})) + this.startError = body?.error || this.t('planninq', 'Could not take the current timetable.') + return + } + await this.load() + }, + + /** + * Why a lesson has no place: the hard wish, a time off the week grid, or no free period and room. + * + * @param {{wish: object|null, reason: string}} row The unplaced row. + * @return {string} + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + blockedBy(row) { + if (row.wish) { + return this.describe(row.wish) + } + return row.reason === 'offGrid' ? this.t('planninq', 'Not on a period of the week grid') : this.t('planninq', 'No free period and room') + }, + /** * A wish in words: what it applies to, who or what, and its kind. * diff --git a/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js index 1f937326..9a93f66d 100644 --- a/src/utils/timetableScenarios.js +++ b/src/utils/timetableScenarios.js @@ -84,3 +84,29 @@ export function brokenSoftRows(scenario) { .map((row) => ({ wish: wishSummary(wishes.get(String(row.wish)) ?? { reference: String(row.wish) }), weight: Number(row.weight ?? 1), lessons: row.lessons ?? [] })) .sort((a, b) => (b.weight * b.lessons.length) - (a.weight * a.lessons.length)) } + +/** + * The broken hard wishes with the lessons that break them. A generated scenario has none; + * an imported timetable can, and they are shown, not hidden. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @param {object|null} scenario The scenario. + * @return {Array<{wish: object|null, lessons: string[]}>} One row per wish. + */ +export function brokenHardRows(scenario) { + const wishes = new Map((scenario?.input?.wishes ?? []).map((wish) => [String(wish.id), wish])) + return (scenario?.brokenWishes ?? []) + .filter((row) => row.strength === 'hard') + .map((row) => ({ wish: wishSummary(wishes.get(String(row.wish)) ?? { reference: String(row.wish) }), lessons: row.lessons ?? [] })) +} + +/** + * Whether the current timetable can be taken into this scenario: an imported one that is not running or published. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @param {object|null} scenario The scenario. + * @return {boolean} + */ +export function canTakeCurrentTimetable(scenario) { + return scenario?.source === 'imported' && !isRunning(scenario) && scenario?.status !== 'published' +} diff --git a/tests/unit/Controller/TimetableScenarioControllerTest.php b/tests/unit/Controller/TimetableScenarioControllerTest.php index d86bc75d..1250ab53 100644 --- a/tests/unit/Controller/TimetableScenarioControllerTest.php +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -28,6 +28,7 @@ use OCA\Planninq\Controller\TimetableScenarioController; use OCA\Planninq\Service\SettingsService; use OCA\Planninq\Service\TimetableGenerationService; +use OCA\Planninq\Service\TimetableScenarioImporter; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\BackgroundJob\IJobList; @@ -36,9 +37,10 @@ use ReflectionMethod; /** - * The generate endpoint. + * The generate and import endpoints. * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 */ class TimetableScenarioControllerTest extends TestCase { @@ -48,13 +50,20 @@ class TimetableScenarioControllerTest extends TestCase { * @param bool $admin Whether the caller is an admin. * @param TimetableGenerationService $generation The generation service. * @param IJobList $jobList The job list. + * @param TimetableScenarioImporter $importer The importer, a stub when not given. * * @return TimetableScenarioController */ - private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList): TimetableScenarioController { + private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList, ?TimetableScenarioImporter $importer=null): TimetableScenarioController { $settings = $this->createMock(SettingsService::class); $settings->method('isCurrentUserAdmin')->willReturn($admin); - return new TimetableScenarioController(request: $this->createMock(IRequest::class), generation: $generation, jobList: $jobList, settingsService: $settings); + return new TimetableScenarioController( + request: $this->createMock(IRequest::class), + generation: $generation, + jobList: $jobList, + settingsService: $settings, + importer: ($importer ?? $this->createMock(TimetableScenarioImporter::class)) + ); }//end controller() /** @@ -107,4 +116,45 @@ public function testARefusedScenarioAnswersBadRequest(): void { self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); self::assertStringContainsString(needle: 'No activities', haystack: $response->getData()['error']); }//end testARefusedScenarioAnswersBadRequest() + /** + * A non-admin cannot take the current timetable into a scenario. + * + * @return void + */ + public function testANonAdminCannotImport(): void { + $importer = $this->createMock(TimetableScenarioImporter::class); + $importer->expects($this->never())->method('importInto'); + + $response = $this->controller(admin: false, generation: $this->createMock(TimetableGenerationService::class), jobList: $this->createMock(IJobList::class), importer: $importer)->importCurrent(id: 's-1'); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertNotEmpty((new ReflectionMethod(TimetableScenarioController::class, 'importCurrent'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminCannotImport() + + /** + * An admin's import answers with the scenario's status and metrics; a refused one with 400. + * + * @return void + */ + public function testAnAdminImportsOrIsToldWhyNot(): void { + $importer = $this->createMock(TimetableScenarioImporter::class); + $importer->method('importInto')->willReturnCallback( + static function (string $id): array { + if ($id === 's-2') { + throw new InvalidArgumentException('The week of this scenario has no scheduled lessons.'); + } + + return ['status' => 'done', 'metrics' => ['hardWishesBroken' => 1]]; + } + ); + $controller = $this->controller(admin: true, generation: $this->createMock(TimetableGenerationService::class), jobList: $this->createMock(IJobList::class), importer: $importer); + + $response = $controller->importCurrent(id: 's-1'); + self::assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + self::assertSame(expected: ['id' => 's-1', 'status' => 'done', 'metrics' => ['hardWishesBroken' => 1]], actual: $response->getData()); + + $refused = $controller->importCurrent(id: 's-2'); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $refused->getStatus()); + self::assertStringContainsString(needle: 'no scheduled lessons', haystack: $refused->getData()['error']); + }//end testAnAdminImportsOrIsToldWhyNot() }//end class diff --git a/tests/unit/Service/TimetableScenarioImporterTest.php b/tests/unit/Service/TimetableScenarioImporterTest.php new file mode 100644 index 00000000..7fe50d43 --- /dev/null +++ b/tests/unit/Service/TimetableScenarioImporterTest.php @@ -0,0 +1,236 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use InvalidArgumentException; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Service\TimetableScenarioImporter; +use OCA\Planninq\Service\TimetableScenarioStore; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * The real store over an in-memory ObjectService holding lessons, a wish and a scenario. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ +class TimetableScenarioImporterTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The fake ObjectService. + * + * @var object + */ + private object $objects; + + /** + * Lessons in the week of 5 October 2026 (one on the next Monday, one cancelled), a hard wish and an imported scenario. + * + * @return void + */ + protected function setUp(): void { + $this->objects = new class { + /** + * Stored objects by schema and id. + * + * @var array>> + */ + public array $stored = ['timetableSession' => [], 'timetableWish' => [], 'timetableScenario' => []]; + + /** + * Saved scenario payloads. + * + * @var array> + */ + public array $saves = []; + + /** + * OpenRegister's find. + * + * @param string $id The id. + * + * @return array|null + */ + public function find(string $id, mixed ...$rest): ?array { + return ($this->stored[(string)$rest['schema']][$id] ?? null); + } + + /** + * OpenRegister's saveObject. + * + * @param array $object The data. + * + * @return array + */ + public function saveObject(array $object, mixed ...$rest): array { + $this->saves[] = $object; + $this->stored[(string)$rest['schema']][(string)$rest['uuid']] = array_merge($object, ['id' => (string)$rest['uuid']]); + return $object; + } + + /** + * OpenRegister's searchObjectsBySlug, applying the startsAt window like OpenRegister does. + * + * @param string $registerSlug The register. + * @param string $schemaSlug The schema. + * @param array $filters The filters. + * + * @return array + */ + public function searchObjectsBySlug(string $registerSlug, string $schemaSlug, array $filters=[], mixed ...$rest): array { + $rows = array_values($this->stored[$schemaSlug] ?? []); + if (isset($filters['startsAt']) === true) { + $rows = array_values( + array_filter( + $rows, + static fn (array $row): bool => strtotime($row['startsAt']) >= strtotime($filters['startsAt']['gte']) + && strtotime($row['startsAt']) <= strtotime($filters['startsAt']['lte']) + ) + ); + } + + return ['results' => $rows]; + } + }; + + $lesson = fn (string $id, string $group, string $subject, string $teacher, string $starts, string $ends, string $room='B12', string $status='scheduled'): array => [ + 'id' => $id, + 'externalRef' => $id, + 'sourceSystem' => 'roster-zermelo', + 'subject' => $subject, + 'startsAt' => $starts, + 'endsAt' => $ends, + 'groupReference' => $group, + 'teacherUserId' => $teacher, + 'roomReference' => $room, + 'status' => $status, + ]; + foreach ([ + $lesson('l-1', '3A', 'English', 'klaas', '2026-10-05T08:30:00+02:00', '2026-10-05T09:20:00+02:00'), + $lesson('l-2', '3A', 'English', 'klaas', '2026-10-07T12:40:00+02:00', '2026-10-07T13:30:00+02:00'), + $lesson('l-3', '3B', 'Maths', 'noor', '2026-10-05T09:20:00+02:00', '2026-10-05T11:00:00+02:00', 'B13'), + $lesson('l-4', '3B', 'Maths', 'noor', '2026-10-06T07:00:00+02:00', '2026-10-06T07:45:00+02:00', 'B13'), + $lesson('l-5', '3A', 'Art', 'piet', '2026-10-12T08:30:00+02:00', '2026-10-12T09:20:00+02:00'), + ] as $row) { + $this->objects->stored['timetableSession'][$row['id']] = $row; + } + + $this->objects->stored['timetableWish']['w-1'] = ['id' => 'w-1', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5', 'wed-6', 'wed-7', 'wed-8'], 'strength' => 'hard']; + $this->objects->stored['timetableScenario']['s-1'] = [ + 'id' => 's-1', + 'title' => 'Current timetable', + 'source' => 'imported', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-30', + 'status' => 'queued', + ]; + }//end setUp() + + /** + * The importer with the default grid. + * + * @return TimetableScenarioImporter + */ + private function importer(): TimetableScenarioImporter { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->objects); + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback(static fn (string $app, string $key, string $default = ''): string => $default); + $grid = new TimetableGridService(appConfig: $config); + + return new TimetableScenarioImporter( + store: new TimetableScenarioStore(container: $container), + grid: $grid, + inputBuilder: new TimetableInputBuilder(dispatcher: $this->createMock(IEventDispatcher::class), appConfig: $config, grid: $grid) + ); + }//end importer() + + /** + * An imported timetable that breaks a hard wish shows that breach in its metrics; a lesson + * off the period times is unplaced with reason offGrid; a two-period lesson keeps its length. + * + * @return void + */ + public function testAnImportedTimetableShowsItsHardWishBreach(): void { + $scenario = $this->importer()->importInto(id: 's-1'); + + self::assertSame(expected: 'done', actual: $scenario['status']); + self::assertSame(expected: 1, actual: $scenario['metrics']['hardWishesBroken']); + self::assertSame(expected: 'w-1', actual: $scenario['brokenWishes'][0]['wish']); + self::assertSame(expected: ['3A:English:2'], actual: $scenario['brokenWishes'][0]['lessons']); + self::assertSame( + expected: [ + ['lesson' => '3A:English:1', 'period' => 'mon-1', 'room' => 'B12'], + ['lesson' => '3B:Maths:1', 'period' => 'mon-2', 'room' => 'B13'], + ['lesson' => '3A:English:2', 'period' => 'wed-6', 'room' => 'B12'], + ], + actual: $this->sortedByLesson($scenario['placements']) + ); + self::assertSame(expected: [['lesson' => '3B:Maths:2', 'wish' => null, 'reason' => 'offGrid']], actual: $scenario['unplaced']); + $lengths = array_column($scenario['input']['lessons'], 'length', 'key'); + self::assertSame(expected: 2, actual: $lengths['3B:Maths:1']); + self::assertArrayNotHasKey(key: '3A:Art:1', array: $lengths); + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: end($this->objects->saves))); + }//end testAnImportedTimetableShowsItsHardWishBreach() + + /** + * A generated scenario, and a week without lessons, are refused. + * + * @return void + */ + public function testAGeneratedScenarioOrAnEmptyWeekIsRefused(): void { + $this->objects->stored['timetableScenario']['s-2'] = array_merge($this->objects->stored['timetableScenario']['s-1'], ['id' => 's-2', 'source' => 'generated']); + $this->objects->stored['timetableScenario']['s-3'] = array_merge($this->objects->stored['timetableScenario']['s-1'], ['id' => 's-3', 'weekOf' => '2027-01-04']); + + foreach (['s-2' => 'imported scenario', 's-3' => 'no scheduled lessons'] as $id => $message) { + try { + $this->importer()->importInto(id: $id); + self::fail('Scenario '.$id.' was imported.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString(needle: $message, haystack: $e->getMessage()); + } + } + }//end testAGeneratedScenarioOrAnEmptyWeekIsRefused() + + /** + * Placements sorted by period order then lesson, for a stable comparison. + * + * @param array> $placements The placements. + * + * @return array> + */ + private function sortedByLesson(array $placements): array { + usort($placements, static fn (array $one, array $two): int => [$one['period'][0] === 'w', $one['period'], $one['lesson']] <=> [$two['period'][0] === 'w', $two['period'], $two['lesson']]); + return $placements; + }//end sortedByLesson() +}//end class diff --git a/tests/vitest/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js index 71bcd8ea..43ad4aff 100644 --- a/tests/vitest/timetableScenarios.spec.js +++ b/tests/vitest/timetableScenarios.spec.js @@ -4,7 +4,7 @@ * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 */ import { describe, expect, it } from 'vitest' -import { brokenSoftRows, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' +import { brokenHardRows, brokenSoftRows, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' const scenario = { status: 'running', @@ -59,3 +59,33 @@ describe('the lists (scenario: a lesson that cannot be placed is listed with its expect(unplacedRows({ unplaced: [{ lesson: 'x', wish: 'gone' }] })[0].wish.reference).toBe('gone') }) }) + +describe('an imported scenario (scenario: an imported timetable that breaks a hard wish shows that breach)', () => { + const imported = { + source: 'imported', + status: 'done', + input: scenario.input, + unplaced: [{ lesson: '3B:Maths:2', wish: null, reason: 'offGrid' }], + brokenWishes: [ + { wish: 'w-klaas', strength: 'hard', weight: null, lessons: ['3A:English:2'] }, + { wish: 'w-noor', strength: 'soft', weight: 1, lessons: ['3B:Maths:1'] }, + ], + } + + it('lists the broken hard wishes apart from the soft ones', () => { + expect(brokenHardRows(imported)).toEqual([{ wish: { appliesTo: 'teacher', reference: 'klaas', kind: 'unavailable', periods: ['wed-5', 'wed-6'] }, lessons: ['3A:English:2'] }]) + expect(brokenSoftRows(imported).map((row) => row.lessons)).toEqual([['3B:Maths:1']]) + expect(brokenHardRows(scenario)).toEqual([]) + }) + + it('keeps the reason of a lesson that is not on a period of the grid', () => { + expect(unplacedRows(imported)).toEqual([{ lesson: '3B:Maths:2', wish: null, reason: 'offGrid' }]) + }) + + it('offers the current timetable only on an imported scenario that is not running or published', () => { + expect(canTakeCurrentTimetable(imported)).toBe(true) + expect(canTakeCurrentTimetable({ ...imported, status: 'published' })).toBe(false) + expect(canTakeCurrentTimetable({ ...imported, source: 'generated' })).toBe(false) + expect(canTakeCurrentTimetable(null)).toBe(false) + }) +}) From 6bc8cd5d0d34cf733a818dbb617883f9d0e603f7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 07:50:05 +0200 Subject: [PATCH 08/14] style(timetable): wrap the importer's input line under 150 characters (phpcs) --- lib/Service/TimetableScenarioImporter.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Service/TimetableScenarioImporter.php b/lib/Service/TimetableScenarioImporter.php index 26aa9190..81d00d22 100644 --- a/lib/Service/TimetableScenarioImporter.php +++ b/lib/Service/TimetableScenarioImporter.php @@ -86,7 +86,13 @@ public function importInto(string $id): array { $week = $this->weekOf(sessions: $sessions); $wishes = array_map(fn (array $wish): array => $this->inputBuilder->solverWish(wish: $wish), $this->store->wishes()); - $input = new SolverInput(periods: $this->grid->periodKeys(), rooms: $week['rooms'], lessons: $week['lessons'], wishes: $wishes, source: 'imported'); + $input = new SolverInput( + periods: $this->grid->periodKeys(), + rooms: $week['rooms'], + lessons: $week['lessons'], + wishes: $wishes, + source: 'imported' + ); $score = $this->scorer->score(input: $input, placements: $week['placements']); $scenario = array_merge( From a94982abc845aa96292db9f3ae8078c9e64d83cd Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 07:53:11 +0200 Subject: [PATCH 09/14] feat(timetable): compare two or three scenarios side by side, best value marked, differing lessons listed --- l10n/be.js | 21 +- l10n/be.json | 21 +- l10n/bg.js | 21 +- l10n/bg.json | 21 +- l10n/bs.js | 21 +- l10n/bs.json | 21 +- l10n/ca.js | 21 +- l10n/ca.json | 21 +- l10n/cs.js | 21 +- l10n/cs.json | 21 +- l10n/da.js | 21 +- l10n/da.json | 21 +- l10n/de.js | 21 +- l10n/de.json | 21 +- l10n/el.js | 21 +- l10n/el.json | 21 +- l10n/en.js | 21 +- l10n/en.json | 21 +- l10n/es.js | 21 +- l10n/es.json | 21 +- l10n/et.js | 21 +- l10n/et.json | 21 +- l10n/fi.js | 21 +- l10n/fi.json | 21 +- l10n/fr.js | 21 +- l10n/fr.json | 21 +- l10n/ga.js | 21 +- l10n/ga.json | 21 +- l10n/hr.js | 21 +- l10n/hr.json | 21 +- l10n/hu.js | 21 +- l10n/hu.json | 21 +- l10n/is.js | 21 +- l10n/is.json | 21 +- l10n/it.js | 21 +- l10n/it.json | 21 +- l10n/lb.js | 21 +- l10n/lb.json | 21 +- l10n/lt.js | 21 +- l10n/lt.json | 21 +- l10n/lv.js | 21 +- l10n/lv.json | 21 +- l10n/mk.js | 21 +- l10n/mk.json | 21 +- l10n/mt.js | 21 +- l10n/mt.json | 21 +- l10n/nb.js | 21 +- l10n/nb.json | 21 +- l10n/nl.js | 21 +- l10n/nl.json | 21 +- l10n/pl.js | 21 +- l10n/pl.json | 21 +- l10n/pt.js | 21 +- l10n/pt.json | 21 +- l10n/rm.js | 21 +- l10n/rm.json | 21 +- l10n/ro.js | 21 +- l10n/ro.json | 21 +- l10n/ru.js | 21 +- l10n/ru.json | 21 +- l10n/sk.js | 21 +- l10n/sk.json | 21 +- l10n/sl.js | 21 +- l10n/sl.json | 21 +- l10n/sq.js | 21 +- l10n/sq.json | 21 +- l10n/sr.js | 21 +- l10n/sr.json | 21 +- l10n/sv.js | 21 +- l10n/sv.json | 21 +- l10n/tr.js | 21 +- l10n/tr.json | 21 +- l10n/uk.js | 21 +- l10n/uk.json | 21 +- .../changes/timetabling-generator/tasks.md | 2 +- src/components/TimetableScenarioCompare.vue | 190 ++++++++++++++++++ src/manifest.json | 4 +- src/registry.js | 2 + src/utils/scenarioCompare.js | 81 ++++++++ tests/e2e/timetable-generator.spec.ts | 29 ++- tests/vitest/scenarioCompare.spec.js | 70 +++++++ 81 files changed, 1854 insertions(+), 78 deletions(-) create mode 100644 src/components/TimetableScenarioCompare.vue create mode 100644 src/utils/scenarioCompare.js create mode 100644 tests/vitest/scenarioCompare.spec.js diff --git a/l10n/be.js b/l10n/be.js index 7af566af..fa681aa9 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Узяць бягучы расклад", "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", "Broken hard wishes": "Парушаныя абавязковыя пажаданні", - "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі" + "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі", + "Compare scenarios": "Параўнаць сцэнарыі", + "Scenarios to compare": "Сцэнарыі для параўнання", + "Choose two or three scenarios to compare them.": "Выберыце два ці тры сцэнарыі, каб параўнаць іх.", + "Measure": "Паказчык", + "Best": "Найлепшы", + "Lessons that differ": "Урокі, якія адрозніваюцца", + "No lesson differs.": "Ніводзін урок не адрозніваецца.", + "Not placed": "Не размешчаны", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Размешчаныя ўрокі", + "Lessons without a place": "Урокі без месца", + "Clashes": "Канфлікты", + "Hard wishes broken": "Парушаныя абавязковыя пажаданні", + "Soft wishes broken": "Парушаныя гнуткія пажаданні", + "Soft wishes broken, weighted": "Парушаныя гнуткія пажаданні, узважаныя", + "Free periods between lessons, all teachers": "Вокны, усе настаўнікі", + "Most free periods of one teacher": "Найбольш вокнаў у аднаго настаўніка", + "Most lessons of one group on a day": "Найбольш урокаў адной групы за дзень", + "Room use": "Выкарыстанне кабінетаў" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 581baabd..d0ae7db8 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Узяць бягучы расклад", "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", "Broken hard wishes": "Парушаныя абавязковыя пажаданні", - "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі" + "Not on a period of the week grid": "Не на ўроку тыднёвай сеткі", + "Compare scenarios": "Параўнаць сцэнарыі", + "Scenarios to compare": "Сцэнарыі для параўнання", + "Choose two or three scenarios to compare them.": "Выберыце два ці тры сцэнарыі, каб параўнаць іх.", + "Measure": "Паказчык", + "Best": "Найлепшы", + "Lessons that differ": "Урокі, якія адрозніваюцца", + "No lesson differs.": "Ніводзін урок не адрозніваецца.", + "Not placed": "Не размешчаны", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Размешчаныя ўрокі", + "Lessons without a place": "Урокі без месца", + "Clashes": "Канфлікты", + "Hard wishes broken": "Парушаныя абавязковыя пажаданні", + "Soft wishes broken": "Парушаныя гнуткія пажаданні", + "Soft wishes broken, weighted": "Парушаныя гнуткія пажаданні, узважаныя", + "Free periods between lessons, all teachers": "Вокны, усе настаўнікі", + "Most free periods of one teacher": "Найбольш вокнаў у аднаго настаўніка", + "Most lessons of one group on a day": "Найбольш урокаў адной групы за дзень", + "Room use": "Выкарыстанне кабінетаў" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 339339c0..9f28e486 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Вземи текущото разписание", "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", "Broken hard wishes": "Нарушени задължителни желания", - "Not on a period of the week grid": "Не е в час от седмичната мрежа" + "Not on a period of the week grid": "Не е в час от седмичната мрежа", + "Compare scenarios": "Сравняване на сценарии", + "Scenarios to compare": "Сценарии за сравнение", + "Choose two or three scenarios to compare them.": "Изберете два или три сценария, за да ги сравните.", + "Measure": "Показател", + "Best": "Най-добър", + "Lessons that differ": "Часове с разлика", + "No lesson differs.": "Няма час с разлика.", + "Not placed": "Не е поставен", + "{period} in {room}": "{period} в {room}", + "Lessons placed": "Поставени часове", + "Lessons without a place": "Часове без място", + "Clashes": "Конфликти", + "Hard wishes broken": "Нарушени задължителни желания", + "Soft wishes broken": "Нарушени гъвкави желания", + "Soft wishes broken, weighted": "Нарушени гъвкави желания, претеглени", + "Free periods between lessons, all teachers": "Свободни часове, всички учители", + "Most free periods of one teacher": "Най-много свободни часове на един учител", + "Most lessons of one group on a day": "Най-много часове на една група за ден", + "Room use": "Използване на стаите" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index 6fa000e1..7cf9b591 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Вземи текущото разписание", "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", "Broken hard wishes": "Нарушени задължителни желания", - "Not on a period of the week grid": "Не е в час от седмичната мрежа" + "Not on a period of the week grid": "Не е в час от седмичната мрежа", + "Compare scenarios": "Сравняване на сценарии", + "Scenarios to compare": "Сценарии за сравнение", + "Choose two or three scenarios to compare them.": "Изберете два или три сценария, за да ги сравните.", + "Measure": "Показател", + "Best": "Най-добър", + "Lessons that differ": "Часове с разлика", + "No lesson differs.": "Няма час с разлика.", + "Not placed": "Не е поставен", + "{period} in {room}": "{period} в {room}", + "Lessons placed": "Поставени часове", + "Lessons without a place": "Часове без място", + "Clashes": "Конфликти", + "Hard wishes broken": "Нарушени задължителни желания", + "Soft wishes broken": "Нарушени гъвкави желания", + "Soft wishes broken, weighted": "Нарушени гъвкави желания, претеглени", + "Free periods between lessons, all teachers": "Свободни часове, всички учители", + "Most free periods of one teacher": "Най-много свободни часове на един учител", + "Most lessons of one group on a day": "Най-много часове на една група за ден", + "Room use": "Използване на стаите" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index 69223da8..73660e13 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Preuzmi trenutni raspored", "Could not take the current timetable.": "Trenutni raspored nije moguće preuzeti.", "Broken hard wishes": "Prekršene obavezne želje", - "Not on a period of the week grid": "Nije na času sedmične mreže" + "Not on a period of the week grid": "Nije na času sedmične mreže", + "Compare scenarios": "Uporedi scenarije", + "Scenarios to compare": "Scenariji za poređenje", + "Choose two or three scenarios to compare them.": "Odaberite dva ili tri scenarija za poređenje.", + "Measure": "Mjera", + "Best": "Najbolje", + "Lessons that differ": "Časovi koji se razlikuju", + "No lesson differs.": "Nijedan čas se ne razlikuje.", + "Not placed": "Nije smješteno", + "{period} in {room}": "{period} u {room}", + "Lessons placed": "Smješteni časovi", + "Lessons without a place": "Časovi bez mjesta", + "Clashes": "Sukobi", + "Hard wishes broken": "Prekršene obavezne želje", + "Soft wishes broken": "Prekršene fleksibilne želje", + "Soft wishes broken, weighted": "Prekršene fleksibilne želje, ponderisano", + "Free periods between lessons, all teachers": "Prazni časovi, svi nastavnici", + "Most free periods of one teacher": "Najviše praznih časova jednog nastavnika", + "Most lessons of one group on a day": "Najviše časova jedne grupe u danu", + "Room use": "Korištenje učionica" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 8eabb998..3ee35c20 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Preuzmi trenutni raspored", "Could not take the current timetable.": "Trenutni raspored nije moguće preuzeti.", "Broken hard wishes": "Prekršene obavezne želje", - "Not on a period of the week grid": "Nije na času sedmične mreže" + "Not on a period of the week grid": "Nije na času sedmične mreže", + "Compare scenarios": "Uporedi scenarije", + "Scenarios to compare": "Scenariji za poređenje", + "Choose two or three scenarios to compare them.": "Odaberite dva ili tri scenarija za poređenje.", + "Measure": "Mjera", + "Best": "Najbolje", + "Lessons that differ": "Časovi koji se razlikuju", + "No lesson differs.": "Nijedan čas se ne razlikuje.", + "Not placed": "Nije smješteno", + "{period} in {room}": "{period} u {room}", + "Lessons placed": "Smješteni časovi", + "Lessons without a place": "Časovi bez mjesta", + "Clashes": "Sukobi", + "Hard wishes broken": "Prekršene obavezne želje", + "Soft wishes broken": "Prekršene fleksibilne želje", + "Soft wishes broken, weighted": "Prekršene fleksibilne želje, ponderisano", + "Free periods between lessons, all teachers": "Prazni časovi, svi nastavnici", + "Most free periods of one teacher": "Najviše praznih časova jednog nastavnika", + "Most lessons of one group on a day": "Najviše časova jedne grupe u danu", + "Room use": "Korištenje učionica" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index a9c95c55..85c3cfe5 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Agafa l'horari actual", "Could not take the current timetable.": "No s'ha pogut agafar l'horari actual.", "Broken hard wishes": "Desitjos obligatoris incomplerts", - "Not on a period of the week grid": "No és en una franja de la graella setmanal" + "Not on a period of the week grid": "No és en una franja de la graella setmanal", + "Compare scenarios": "Compara escenaris", + "Scenarios to compare": "Escenaris per comparar", + "Choose two or three scenarios to compare them.": "Trieu dos o tres escenaris per comparar-los.", + "Measure": "Mesura", + "Best": "El millor", + "Lessons that differ": "Classes que difereixen", + "No lesson differs.": "Cap classe no difereix.", + "Not placed": "Sense col·locar", + "{period} in {room}": "{period} a {room}", + "Lessons placed": "Classes col·locades", + "Lessons without a place": "Classes sense lloc", + "Clashes": "Conflictes", + "Hard wishes broken": "Desitjos estrictes incomplerts", + "Soft wishes broken": "Desitjos flexibles incomplerts", + "Soft wishes broken, weighted": "Desitjos flexibles incomplerts, ponderats", + "Free periods between lessons, all teachers": "Hores lliures, tots els docents", + "Most free periods of one teacher": "Més hores lliures d'un docent", + "Most lessons of one group on a day": "Més classes d'un grup en un dia", + "Room use": "Ús de les aules" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index dc1b6c30..0180ea79 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Agafa l'horari actual", "Could not take the current timetable.": "No s'ha pogut agafar l'horari actual.", "Broken hard wishes": "Desitjos obligatoris incomplerts", - "Not on a period of the week grid": "No és en una franja de la graella setmanal" + "Not on a period of the week grid": "No és en una franja de la graella setmanal", + "Compare scenarios": "Compara escenaris", + "Scenarios to compare": "Escenaris per comparar", + "Choose two or three scenarios to compare them.": "Trieu dos o tres escenaris per comparar-los.", + "Measure": "Mesura", + "Best": "El millor", + "Lessons that differ": "Classes que difereixen", + "No lesson differs.": "Cap classe no difereix.", + "Not placed": "Sense col·locar", + "{period} in {room}": "{period} a {room}", + "Lessons placed": "Classes col·locades", + "Lessons without a place": "Classes sense lloc", + "Clashes": "Conflictes", + "Hard wishes broken": "Desitjos estrictes incomplerts", + "Soft wishes broken": "Desitjos flexibles incomplerts", + "Soft wishes broken, weighted": "Desitjos flexibles incomplerts, ponderats", + "Free periods between lessons, all teachers": "Hores lliures, tots els docents", + "Most free periods of one teacher": "Més hores lliures d'un docent", + "Most lessons of one group on a day": "Més classes d'un grup en un dia", + "Room use": "Ús de les aules" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 0b9c3eea..5a777b93 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Převzít aktuální rozvrh", "Could not take the current timetable.": "Aktuální rozvrh se nepodařilo převzít.", "Broken hard wishes": "Porušená závazná přání", - "Not on a period of the week grid": "Není na hodině týdenní mřížky" + "Not on a period of the week grid": "Není na hodině týdenní mřížky", + "Compare scenarios": "Porovnat scénáře", + "Scenarios to compare": "Scénáře k porovnání", + "Choose two or three scenarios to compare them.": "Vyberte dva nebo tři scénáře k porovnání.", + "Measure": "Ukazatel", + "Best": "Nejlepší", + "Lessons that differ": "Hodiny, které se liší", + "No lesson differs.": "Žádná hodina se neliší.", + "Not placed": "Neumístěno", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umístěné hodiny", + "Lessons without a place": "Hodiny bez místa", + "Clashes": "Kolize", + "Hard wishes broken": "Porušená pevná přání", + "Soft wishes broken": "Porušená měkká přání", + "Soft wishes broken, weighted": "Porušená měkká přání, vážená", + "Free periods between lessons, all teachers": "Volné hodiny, všichni učitelé", + "Most free periods of one teacher": "Nejvíc volných hodin jednoho učitele", + "Most lessons of one group on a day": "Nejvíc hodin jedné skupiny za den", + "Room use": "Využití učeben" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index c7b651ce..1cc94da3 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Převzít aktuální rozvrh", "Could not take the current timetable.": "Aktuální rozvrh se nepodařilo převzít.", "Broken hard wishes": "Porušená závazná přání", - "Not on a period of the week grid": "Není na hodině týdenní mřížky" + "Not on a period of the week grid": "Není na hodině týdenní mřížky", + "Compare scenarios": "Porovnat scénáře", + "Scenarios to compare": "Scénáře k porovnání", + "Choose two or three scenarios to compare them.": "Vyberte dva nebo tři scénáře k porovnání.", + "Measure": "Ukazatel", + "Best": "Nejlepší", + "Lessons that differ": "Hodiny, které se liší", + "No lesson differs.": "Žádná hodina se neliší.", + "Not placed": "Neumístěno", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umístěné hodiny", + "Lessons without a place": "Hodiny bez místa", + "Clashes": "Kolize", + "Hard wishes broken": "Porušená pevná přání", + "Soft wishes broken": "Porušená měkká přání", + "Soft wishes broken, weighted": "Porušená měkká přání, vážená", + "Free periods between lessons, all teachers": "Volné hodiny, všichni učitelé", + "Most free periods of one teacher": "Nejvíc volných hodin jednoho učitele", + "Most lessons of one group on a day": "Nejvíc hodin jedné skupiny za den", + "Room use": "Využití učeben" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 2b10c96b..37982ac6 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Brug det nuværende skema", "Could not take the current timetable.": "Det nuværende skema kunne ikke hentes.", "Broken hard wishes": "Brudte hårde ønsker", - "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet" + "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet", + "Compare scenarios": "Sammenlign scenarier", + "Scenarios to compare": "Scenarier at sammenligne", + "Choose two or three scenarios to compare them.": "Vælg to eller tre scenarier for at sammenligne dem.", + "Measure": "Måling", + "Best": "Bedst", + "Lessons that differ": "Lektioner der er forskellige", + "No lesson differs.": "Ingen lektion er forskellig.", + "Not placed": "Ikke placeret", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Placerede lektioner", + "Lessons without a place": "Lektioner uden plads", + "Clashes": "Konflikter", + "Hard wishes broken": "Brudte hårde ønsker", + "Soft wishes broken": "Brudte bløde ønsker", + "Soft wishes broken, weighted": "Brudte bløde ønsker, vægtet", + "Free periods between lessons, all teachers": "Mellemtimer, alle lærere", + "Most free periods of one teacher": "Flest mellemtimer for én lærer", + "Most lessons of one group on a day": "Flest lektioner for én gruppe på en dag", + "Room use": "Lokalebrug" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 2d37a1d3..0401080c 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Brug det nuværende skema", "Could not take the current timetable.": "Det nuværende skema kunne ikke hentes.", "Broken hard wishes": "Brudte hårde ønsker", - "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet" + "Not on a period of the week grid": "Ikke på en lektion i ugeskemaet", + "Compare scenarios": "Sammenlign scenarier", + "Scenarios to compare": "Scenarier at sammenligne", + "Choose two or three scenarios to compare them.": "Vælg to eller tre scenarier for at sammenligne dem.", + "Measure": "Måling", + "Best": "Bedst", + "Lessons that differ": "Lektioner der er forskellige", + "No lesson differs.": "Ingen lektion er forskellig.", + "Not placed": "Ikke placeret", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Placerede lektioner", + "Lessons without a place": "Lektioner uden plads", + "Clashes": "Konflikter", + "Hard wishes broken": "Brudte hårde ønsker", + "Soft wishes broken": "Brudte bløde ønsker", + "Soft wishes broken, weighted": "Brudte bløde ønsker, vægtet", + "Free periods between lessons, all teachers": "Mellemtimer, alle lærere", + "Most free periods of one teacher": "Flest mellemtimer for én lærer", + "Most lessons of one group on a day": "Flest lektioner for én gruppe på en dag", + "Room use": "Lokalebrug" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index c76e9477..d3c85ed9 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Aktuellen Stundenplan übernehmen", "Could not take the current timetable.": "Der aktuelle Stundenplan konnte nicht übernommen werden.", "Broken hard wishes": "Verletzte harte Wünsche", - "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters" + "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters", + "Compare scenarios": "Szenarien vergleichen", + "Scenarios to compare": "Zu vergleichende Szenarien", + "Choose two or three scenarios to compare them.": "Wählen Sie zwei oder drei Szenarien zum Vergleich.", + "Measure": "Kennzahl", + "Best": "Bester Wert", + "Lessons that differ": "Abweichende Stunden", + "No lesson differs.": "Keine Stunde weicht ab.", + "Not placed": "Nicht platziert", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Platzierte Stunden", + "Lessons without a place": "Stunden ohne Platz", + "Clashes": "Konflikte", + "Hard wishes broken": "Verletzte harte Wünsche", + "Soft wishes broken": "Verletzte weiche Wünsche", + "Soft wishes broken, weighted": "Verletzte weiche Wünsche, gewichtet", + "Free periods between lessons, all teachers": "Freistunden, alle Lehrkräfte", + "Most free periods of one teacher": "Meiste Freistunden einer Lehrkraft", + "Most lessons of one group on a day": "Meiste Stunden einer Gruppe an einem Tag", + "Room use": "Raumnutzung" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index e981f85e..8e3f37e0 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Aktuellen Stundenplan übernehmen", "Could not take the current timetable.": "Der aktuelle Stundenplan konnte nicht übernommen werden.", "Broken hard wishes": "Verletzte harte Wünsche", - "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters" + "Not on a period of the week grid": "Nicht auf einer Stunde des Wochenrasters", + "Compare scenarios": "Szenarien vergleichen", + "Scenarios to compare": "Zu vergleichende Szenarien", + "Choose two or three scenarios to compare them.": "Wählen Sie zwei oder drei Szenarien zum Vergleich.", + "Measure": "Kennzahl", + "Best": "Bester Wert", + "Lessons that differ": "Abweichende Stunden", + "No lesson differs.": "Keine Stunde weicht ab.", + "Not placed": "Nicht platziert", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Platzierte Stunden", + "Lessons without a place": "Stunden ohne Platz", + "Clashes": "Konflikte", + "Hard wishes broken": "Verletzte harte Wünsche", + "Soft wishes broken": "Verletzte weiche Wünsche", + "Soft wishes broken, weighted": "Verletzte weiche Wünsche, gewichtet", + "Free periods between lessons, all teachers": "Freistunden, alle Lehrkräfte", + "Most free periods of one teacher": "Meiste Freistunden einer Lehrkraft", + "Most lessons of one group on a day": "Meiste Stunden einer Gruppe an einem Tag", + "Room use": "Raumnutzung" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index ab16ee5b..1a93ce73 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", - "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος" + "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος", + "Compare scenarios": "Σύγκριση σεναρίων", + "Scenarios to compare": "Σενάρια προς σύγκριση", + "Choose two or three scenarios to compare them.": "Επιλέξτε δύο ή τρία σενάρια για σύγκριση.", + "Measure": "Μέτρο", + "Best": "Καλύτερο", + "Lessons that differ": "Μαθήματα που διαφέρουν", + "No lesson differs.": "Κανένα μάθημα δεν διαφέρει.", + "Not placed": "Δεν τοποθετήθηκε", + "{period} in {room}": "{period} στην {room}", + "Lessons placed": "Τοποθετημένα μαθήματα", + "Lessons without a place": "Μαθήματα χωρίς θέση", + "Clashes": "Συγκρούσεις", + "Hard wishes broken": "Υποχρεωτικές επιθυμίες που δεν τηρήθηκαν", + "Soft wishes broken": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν", + "Soft wishes broken, weighted": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν, σταθμισμένες", + "Free periods between lessons, all teachers": "Κενά, όλοι οι εκπαιδευτικοί", + "Most free periods of one teacher": "Τα περισσότερα κενά ενός εκπαιδευτικού", + "Most lessons of one group on a day": "Τα περισσότερα μαθήματα μιας ομάδας σε μια ημέρα", + "Room use": "Χρήση αιθουσών" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 4cce15e6..fa28e650 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", - "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος" + "Not on a period of the week grid": "Όχι σε ώρα του εβδομαδιαίου πλέγματος", + "Compare scenarios": "Σύγκριση σεναρίων", + "Scenarios to compare": "Σενάρια προς σύγκριση", + "Choose two or three scenarios to compare them.": "Επιλέξτε δύο ή τρία σενάρια για σύγκριση.", + "Measure": "Μέτρο", + "Best": "Καλύτερο", + "Lessons that differ": "Μαθήματα που διαφέρουν", + "No lesson differs.": "Κανένα μάθημα δεν διαφέρει.", + "Not placed": "Δεν τοποθετήθηκε", + "{period} in {room}": "{period} στην {room}", + "Lessons placed": "Τοποθετημένα μαθήματα", + "Lessons without a place": "Μαθήματα χωρίς θέση", + "Clashes": "Συγκρούσεις", + "Hard wishes broken": "Υποχρεωτικές επιθυμίες που δεν τηρήθηκαν", + "Soft wishes broken": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν", + "Soft wishes broken, weighted": "Ευέλικτες επιθυμίες που δεν τηρήθηκαν, σταθμισμένες", + "Free periods between lessons, all teachers": "Κενά, όλοι οι εκπαιδευτικοί", + "Most free periods of one teacher": "Τα περισσότερα κενά ενός εκπαιδευτικού", + "Most lessons of one group on a day": "Τα περισσότερα μαθήματα μιας ομάδας σε μια ημέρα", + "Room use": "Χρήση αιθουσών" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 4f2b282d..15ea226c 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1027,7 +1027,26 @@ OC.L10N.register( "Take the current timetable": "Take the current timetable", "Could not take the current timetable.": "Could not take the current timetable.", "Broken hard wishes": "Broken hard wishes", - "Not on a period of the week grid": "Not on a period of the week grid" + "Not on a period of the week grid": "Not on a period of the week grid", + "Compare scenarios": "Compare scenarios", + "Scenarios to compare": "Scenarios to compare", + "Choose two or three scenarios to compare them.": "Choose two or three scenarios to compare them.", + "Measure": "Measure", + "Best": "Best", + "Lessons that differ": "Lessons that differ", + "No lesson differs.": "No lesson differs.", + "Not placed": "Not placed", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Lessons placed", + "Lessons without a place": "Lessons without a place", + "Clashes": "Clashes", + "Hard wishes broken": "Hard wishes broken", + "Soft wishes broken": "Soft wishes broken", + "Soft wishes broken, weighted": "Soft wishes broken, weighted", + "Free periods between lessons, all teachers": "Free periods between lessons, all teachers", + "Most free periods of one teacher": "Most free periods of one teacher", + "Most lessons of one group on a day": "Most lessons of one group on a day", + "Room use": "Room use" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index a0712641..45bedc90 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1026,7 +1026,26 @@ "Take the current timetable": "Take the current timetable", "Could not take the current timetable.": "Could not take the current timetable.", "Broken hard wishes": "Broken hard wishes", - "Not on a period of the week grid": "Not on a period of the week grid" + "Not on a period of the week grid": "Not on a period of the week grid", + "Compare scenarios": "Compare scenarios", + "Scenarios to compare": "Scenarios to compare", + "Choose two or three scenarios to compare them.": "Choose two or three scenarios to compare them.", + "Measure": "Measure", + "Best": "Best", + "Lessons that differ": "Lessons that differ", + "No lesson differs.": "No lesson differs.", + "Not placed": "Not placed", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Lessons placed", + "Lessons without a place": "Lessons without a place", + "Clashes": "Clashes", + "Hard wishes broken": "Hard wishes broken", + "Soft wishes broken": "Soft wishes broken", + "Soft wishes broken, weighted": "Soft wishes broken, weighted", + "Free periods between lessons, all teachers": "Free periods between lessons, all teachers", + "Most free periods of one teacher": "Most free periods of one teacher", + "Most lessons of one group on a day": "Most lessons of one group on a day", + "Room use": "Room use" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index 034e8488..724e58db 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Tomar el horario actual", "Could not take the current timetable.": "No se pudo tomar el horario actual.", "Broken hard wishes": "Deseos obligatorios incumplidos", - "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal" + "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal", + "Compare scenarios": "Comparar escenarios", + "Scenarios to compare": "Escenarios que comparar", + "Choose two or three scenarios to compare them.": "Elija dos o tres escenarios para compararlos.", + "Measure": "Medida", + "Best": "Mejor", + "Lessons that differ": "Clases que difieren", + "No lesson differs.": "Ninguna clase difiere.", + "Not placed": "Sin colocar", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Clases colocadas", + "Lessons without a place": "Clases sin lugar", + "Clashes": "Choques", + "Hard wishes broken": "Deseos estrictos incumplidos", + "Soft wishes broken": "Deseos flexibles incumplidos", + "Soft wishes broken, weighted": "Deseos flexibles incumplidos, ponderados", + "Free periods between lessons, all teachers": "Horas libres entre clases, todos los docentes", + "Most free periods of one teacher": "Más horas libres de un docente", + "Most lessons of one group on a day": "Más clases de un grupo en un día", + "Room use": "Uso de aulas" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index 3a25fa50..fb3e0448 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Tomar el horario actual", "Could not take the current timetable.": "No se pudo tomar el horario actual.", "Broken hard wishes": "Deseos obligatorios incumplidos", - "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal" + "Not on a period of the week grid": "No está en una franja de la cuadrícula semanal", + "Compare scenarios": "Comparar escenarios", + "Scenarios to compare": "Escenarios que comparar", + "Choose two or three scenarios to compare them.": "Elija dos o tres escenarios para compararlos.", + "Measure": "Medida", + "Best": "Mejor", + "Lessons that differ": "Clases que difieren", + "No lesson differs.": "Ninguna clase difiere.", + "Not placed": "Sin colocar", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Clases colocadas", + "Lessons without a place": "Clases sin lugar", + "Clashes": "Choques", + "Hard wishes broken": "Deseos estrictos incumplidos", + "Soft wishes broken": "Deseos flexibles incumplidos", + "Soft wishes broken, weighted": "Deseos flexibles incumplidos, ponderados", + "Free periods between lessons, all teachers": "Horas libres entre clases, todos los docentes", + "Most free periods of one teacher": "Más horas libres de un docente", + "Most lessons of one group on a day": "Más clases de un grupo en un día", + "Room use": "Uso de aulas" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index 579182ac..25de6092 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Võta praegune tunniplaan", "Could not take the current timetable.": "Praegust tunniplaani ei õnnestunud võtta.", "Broken hard wishes": "Rikutud kohustuslikud soovid", - "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil" + "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil", + "Compare scenarios": "Võrdle stsenaariume", + "Scenarios to compare": "Võrreldavad stsenaariumid", + "Choose two or three scenarios to compare them.": "Valige võrdlemiseks kaks või kolm stsenaariumi.", + "Measure": "Näitaja", + "Best": "Parim", + "Lessons that differ": "Erinevad tunnid", + "No lesson differs.": "Ükski tund ei erine.", + "Not placed": "Paigutamata", + "{period} in {room}": "{period} ruumis {room}", + "Lessons placed": "Paigutatud tunnid", + "Lessons without a place": "Kohata tunnid", + "Clashes": "Kokkupõrked", + "Hard wishes broken": "Rikutud kohustuslikud soovid", + "Soft wishes broken": "Rikutud paindlikud soovid", + "Soft wishes broken, weighted": "Rikutud paindlikud soovid, kaalutud", + "Free periods between lessons, all teachers": "Aknatunnid, kõik õpetajad", + "Most free periods of one teacher": "Ühe õpetaja kõige rohkem aknatunde", + "Most lessons of one group on a day": "Ühe rühma kõige rohkem tunde päevas", + "Room use": "Ruumide kasutus" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 295329d5..84494ba7 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Võta praegune tunniplaan", "Could not take the current timetable.": "Praegust tunniplaani ei õnnestunud võtta.", "Broken hard wishes": "Rikutud kohustuslikud soovid", - "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil" + "Not on a period of the week grid": "Ei ole nädalavõrgu tunnil", + "Compare scenarios": "Võrdle stsenaariume", + "Scenarios to compare": "Võrreldavad stsenaariumid", + "Choose two or three scenarios to compare them.": "Valige võrdlemiseks kaks või kolm stsenaariumi.", + "Measure": "Näitaja", + "Best": "Parim", + "Lessons that differ": "Erinevad tunnid", + "No lesson differs.": "Ükski tund ei erine.", + "Not placed": "Paigutamata", + "{period} in {room}": "{period} ruumis {room}", + "Lessons placed": "Paigutatud tunnid", + "Lessons without a place": "Kohata tunnid", + "Clashes": "Kokkupõrked", + "Hard wishes broken": "Rikutud kohustuslikud soovid", + "Soft wishes broken": "Rikutud paindlikud soovid", + "Soft wishes broken, weighted": "Rikutud paindlikud soovid, kaalutud", + "Free periods between lessons, all teachers": "Aknatunnid, kõik õpetajad", + "Most free periods of one teacher": "Ühe õpetaja kõige rohkem aknatunde", + "Most lessons of one group on a day": "Ühe rühma kõige rohkem tunde päevas", + "Room use": "Ruumide kasutus" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 5deb13a8..4a29ada9 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Ota nykyinen lukujärjestys", "Could not take the current timetable.": "Nykyistä lukujärjestystä ei voitu ottaa.", "Broken hard wishes": "Rikotut ehdottomat toiveet", - "Not on a period of the week grid": "Ei viikkoruudukon tunnilla" + "Not on a period of the week grid": "Ei viikkoruudukon tunnilla", + "Compare scenarios": "Vertaa skenaarioita", + "Scenarios to compare": "Verrattavat skenaariot", + "Choose two or three scenarios to compare them.": "Valitse kaksi tai kolme skenaariota verrattavaksi.", + "Measure": "Mittari", + "Best": "Paras", + "Lessons that differ": "Eroavat tunnit", + "No lesson differs.": "Mikään tunti ei eroa.", + "Not placed": "Ei sijoitettu", + "{period} in {room}": "{period} tilassa {room}", + "Lessons placed": "Sijoitetut tunnit", + "Lessons without a place": "Tunnit ilman paikkaa", + "Clashes": "Päällekkäisyydet", + "Hard wishes broken": "Rikotut ehdottomat toiveet", + "Soft wishes broken": "Rikotut joustavat toiveet", + "Soft wishes broken, weighted": "Rikotut joustavat toiveet, painotettu", + "Free periods between lessons, all teachers": "Hyppytunnit, kaikki opettajat", + "Most free periods of one teacher": "Yhden opettajan eniten hyppytunteja", + "Most lessons of one group on a day": "Yhden ryhmän eniten tunteja päivässä", + "Room use": "Tilojen käyttö" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 6510f122..158bd307 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Ota nykyinen lukujärjestys", "Could not take the current timetable.": "Nykyistä lukujärjestystä ei voitu ottaa.", "Broken hard wishes": "Rikotut ehdottomat toiveet", - "Not on a period of the week grid": "Ei viikkoruudukon tunnilla" + "Not on a period of the week grid": "Ei viikkoruudukon tunnilla", + "Compare scenarios": "Vertaa skenaarioita", + "Scenarios to compare": "Verrattavat skenaariot", + "Choose two or three scenarios to compare them.": "Valitse kaksi tai kolme skenaariota verrattavaksi.", + "Measure": "Mittari", + "Best": "Paras", + "Lessons that differ": "Eroavat tunnit", + "No lesson differs.": "Mikään tunti ei eroa.", + "Not placed": "Ei sijoitettu", + "{period} in {room}": "{period} tilassa {room}", + "Lessons placed": "Sijoitetut tunnit", + "Lessons without a place": "Tunnit ilman paikkaa", + "Clashes": "Päällekkäisyydet", + "Hard wishes broken": "Rikotut ehdottomat toiveet", + "Soft wishes broken": "Rikotut joustavat toiveet", + "Soft wishes broken, weighted": "Rikotut joustavat toiveet, painotettu", + "Free periods between lessons, all teachers": "Hyppytunnit, kaikki opettajat", + "Most free periods of one teacher": "Yhden opettajan eniten hyppytunteja", + "Most lessons of one group on a day": "Yhden ryhmän eniten tunteja päivässä", + "Room use": "Tilojen käyttö" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 2db43dc9..363d1eca 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Reprendre l'emploi du temps actuel", "Could not take the current timetable.": "Impossible de reprendre l'emploi du temps actuel.", "Broken hard wishes": "Souhaits stricts non respectés", - "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire" + "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire", + "Compare scenarios": "Comparer des scénarios", + "Scenarios to compare": "Scénarios à comparer", + "Choose two or three scenarios to compare them.": "Choisissez deux ou trois scénarios pour les comparer.", + "Measure": "Mesure", + "Best": "Meilleur", + "Lessons that differ": "Cours qui diffèrent", + "No lesson differs.": "Aucun cours ne diffère.", + "Not placed": "Non placé", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Cours placés", + "Lessons without a place": "Cours sans place", + "Clashes": "Conflits", + "Hard wishes broken": "Souhaits stricts non respectés", + "Soft wishes broken": "Souhaits souples non respectés", + "Soft wishes broken, weighted": "Souhaits souples non respectés, pondérés", + "Free periods between lessons, all teachers": "Heures creuses, tous les enseignants", + "Most free periods of one teacher": "Plus d'heures creuses pour un enseignant", + "Most lessons of one group on a day": "Plus de cours d'un groupe en un jour", + "Room use": "Occupation des salles" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 99e2d58c..24953b3a 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Reprendre l'emploi du temps actuel", "Could not take the current timetable.": "Impossible de reprendre l'emploi du temps actuel.", "Broken hard wishes": "Souhaits stricts non respectés", - "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire" + "Not on a period of the week grid": "Pas sur un créneau de la grille hebdomadaire", + "Compare scenarios": "Comparer des scénarios", + "Scenarios to compare": "Scénarios à comparer", + "Choose two or three scenarios to compare them.": "Choisissez deux ou trois scénarios pour les comparer.", + "Measure": "Mesure", + "Best": "Meilleur", + "Lessons that differ": "Cours qui diffèrent", + "No lesson differs.": "Aucun cours ne diffère.", + "Not placed": "Non placé", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Cours placés", + "Lessons without a place": "Cours sans place", + "Clashes": "Conflits", + "Hard wishes broken": "Souhaits stricts non respectés", + "Soft wishes broken": "Souhaits souples non respectés", + "Soft wishes broken, weighted": "Souhaits souples non respectés, pondérés", + "Free periods between lessons, all teachers": "Heures creuses, tous les enseignants", + "Most free periods of one teacher": "Plus d'heures creuses pour un enseignant", + "Most lessons of one group on a day": "Plus de cours d'un groupe en un jour", + "Room use": "Occupation des salles" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 27a95e3a..d27dd8b7 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Tóg an clár ama reatha", "Could not take the current timetable.": "Níorbh fhéidir an clár ama reatha a thógáil.", "Broken hard wishes": "Mianta éigeantacha briste", - "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine" + "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine", + "Compare scenarios": "Cuir cásanna i gcomparáid", + "Scenarios to compare": "Cásanna le cur i gcomparáid", + "Choose two or three scenarios to compare them.": "Roghnaigh dhá nó trí chás chun iad a chur i gcomparáid.", + "Measure": "Tomhas", + "Best": "Is fearr", + "Lessons that differ": "Ceachtanna atá difriúil", + "No lesson differs.": "Níl aon cheacht difriúil.", + "Not placed": "Gan chur", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Ceachtanna curtha", + "Lessons without a place": "Ceachtanna gan áit", + "Clashes": "Coimhlintí", + "Hard wishes broken": "Mianta docht briste", + "Soft wishes broken": "Mianta boga briste", + "Soft wishes broken, weighted": "Mianta boga briste, ualaithe", + "Free periods between lessons, all teachers": "Tréimhsí saora, gach múinteoir", + "Most free periods of one teacher": "Na tréimhsí saora is mó ag múinteoir amháin", + "Most lessons of one group on a day": "Na ceachtanna is mó ag grúpa amháin in aon lá", + "Room use": "Úsáid seomraí" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 8f4ff534..63dceac8 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Tóg an clár ama reatha", "Could not take the current timetable.": "Níorbh fhéidir an clár ama reatha a thógáil.", "Broken hard wishes": "Mianta éigeantacha briste", - "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine" + "Not on a period of the week grid": "Ní ar thréimhse de ghreille na seachtaine", + "Compare scenarios": "Cuir cásanna i gcomparáid", + "Scenarios to compare": "Cásanna le cur i gcomparáid", + "Choose two or three scenarios to compare them.": "Roghnaigh dhá nó trí chás chun iad a chur i gcomparáid.", + "Measure": "Tomhas", + "Best": "Is fearr", + "Lessons that differ": "Ceachtanna atá difriúil", + "No lesson differs.": "Níl aon cheacht difriúil.", + "Not placed": "Gan chur", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Ceachtanna curtha", + "Lessons without a place": "Ceachtanna gan áit", + "Clashes": "Coimhlintí", + "Hard wishes broken": "Mianta docht briste", + "Soft wishes broken": "Mianta boga briste", + "Soft wishes broken, weighted": "Mianta boga briste, ualaithe", + "Free periods between lessons, all teachers": "Tréimhsí saora, gach múinteoir", + "Most free periods of one teacher": "Na tréimhsí saora is mó ag múinteoir amháin", + "Most lessons of one group on a day": "Na ceachtanna is mó ag grúpa amháin in aon lá", + "Room use": "Úsáid seomraí" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index bc5c723c..5e76baab 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Preuzmi trenutačni raspored", "Could not take the current timetable.": "Trenutačni raspored nije moguće preuzeti.", "Broken hard wishes": "Prekršene obvezne želje", - "Not on a period of the week grid": "Nije na satu tjedne mreže" + "Not on a period of the week grid": "Nije na satu tjedne mreže", + "Compare scenarios": "Usporedi scenarije", + "Scenarios to compare": "Scenariji za usporedbu", + "Choose two or three scenarios to compare them.": "Odaberite dva ili tri scenarija za usporedbu.", + "Measure": "Mjera", + "Best": "Najbolje", + "Lessons that differ": "Sati koji se razlikuju", + "No lesson differs.": "Nijedan sat se ne razlikuje.", + "Not placed": "Nije smješteno", + "{period} in {room}": "{period} u {room}", + "Lessons placed": "Smješteni sati", + "Lessons without a place": "Sati bez mjesta", + "Clashes": "Sukobi", + "Hard wishes broken": "Prekršene obavezne želje", + "Soft wishes broken": "Prekršene fleksibilne želje", + "Soft wishes broken, weighted": "Prekršene fleksibilne želje, ponderirano", + "Free periods between lessons, all teachers": "Prazni sati, svi nastavnici", + "Most free periods of one teacher": "Najviše praznih sati jednog nastavnika", + "Most lessons of one group on a day": "Najviše sati jedne grupe u danu", + "Room use": "Korištenje učionica" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index d5f7c5e6..c6e09467 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Preuzmi trenutačni raspored", "Could not take the current timetable.": "Trenutačni raspored nije moguće preuzeti.", "Broken hard wishes": "Prekršene obvezne želje", - "Not on a period of the week grid": "Nije na satu tjedne mreže" + "Not on a period of the week grid": "Nije na satu tjedne mreže", + "Compare scenarios": "Usporedi scenarije", + "Scenarios to compare": "Scenariji za usporedbu", + "Choose two or three scenarios to compare them.": "Odaberite dva ili tri scenarija za usporedbu.", + "Measure": "Mjera", + "Best": "Najbolje", + "Lessons that differ": "Sati koji se razlikuju", + "No lesson differs.": "Nijedan sat se ne razlikuje.", + "Not placed": "Nije smješteno", + "{period} in {room}": "{period} u {room}", + "Lessons placed": "Smješteni sati", + "Lessons without a place": "Sati bez mjesta", + "Clashes": "Sukobi", + "Hard wishes broken": "Prekršene obavezne želje", + "Soft wishes broken": "Prekršene fleksibilne želje", + "Soft wishes broken, weighted": "Prekršene fleksibilne želje, ponderirano", + "Free periods between lessons, all teachers": "Prazni sati, svi nastavnici", + "Most free periods of one teacher": "Najviše praznih sati jednog nastavnika", + "Most lessons of one group on a day": "Najviše sati jedne grupe u danu", + "Room use": "Korištenje učionica" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 366387cc..ddf057d2 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Jelenlegi órarend átvétele", "Could not take the current timetable.": "Nem sikerült átvenni a jelenlegi órarendet.", "Broken hard wishes": "Megszegett kötelező kívánságok", - "Not on a period of the week grid": "Nem a heti rács egy óráján van" + "Not on a period of the week grid": "Nem a heti rács egy óráján van", + "Compare scenarios": "Forgatókönyvek összehasonlítása", + "Scenarios to compare": "Összehasonlítandó forgatókönyvek", + "Choose two or three scenarios to compare them.": "Válasszon két vagy három forgatókönyvet az összehasonlításhoz.", + "Measure": "Mérőszám", + "Best": "Legjobb", + "Lessons that differ": "Eltérő órák", + "No lesson differs.": "Egyik óra sem tér el.", + "Not placed": "Nincs elhelyezve", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Elhelyezett órák", + "Lessons without a place": "Hely nélküli órák", + "Clashes": "Ütközések", + "Hard wishes broken": "Megsértett kötelező kívánságok", + "Soft wishes broken": "Megsértett rugalmas kívánságok", + "Soft wishes broken, weighted": "Megsértett rugalmas kívánságok, súlyozva", + "Free periods between lessons, all teachers": "Lyukasórák, minden tanár", + "Most free periods of one teacher": "Egy tanár legtöbb lyukasórája", + "Most lessons of one group on a day": "Egy csoport legtöbb órája egy napon", + "Room use": "Teremkihasználtság" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 6efb7953..49bc9e2a 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Jelenlegi órarend átvétele", "Could not take the current timetable.": "Nem sikerült átvenni a jelenlegi órarendet.", "Broken hard wishes": "Megszegett kötelező kívánságok", - "Not on a period of the week grid": "Nem a heti rács egy óráján van" + "Not on a period of the week grid": "Nem a heti rács egy óráján van", + "Compare scenarios": "Forgatókönyvek összehasonlítása", + "Scenarios to compare": "Összehasonlítandó forgatókönyvek", + "Choose two or three scenarios to compare them.": "Válasszon két vagy három forgatókönyvet az összehasonlításhoz.", + "Measure": "Mérőszám", + "Best": "Legjobb", + "Lessons that differ": "Eltérő órák", + "No lesson differs.": "Egyik óra sem tér el.", + "Not placed": "Nincs elhelyezve", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Elhelyezett órák", + "Lessons without a place": "Hely nélküli órák", + "Clashes": "Ütközések", + "Hard wishes broken": "Megsértett kötelező kívánságok", + "Soft wishes broken": "Megsértett rugalmas kívánságok", + "Soft wishes broken, weighted": "Megsértett rugalmas kívánságok, súlyozva", + "Free periods between lessons, all teachers": "Lyukasórák, minden tanár", + "Most free periods of one teacher": "Egy tanár legtöbb lyukasórája", + "Most lessons of one group on a day": "Egy csoport legtöbb órája egy napon", + "Room use": "Teremkihasználtság" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 8ffb6016..16945e2e 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Taka núverandi stundaskrá", "Could not take the current timetable.": "Ekki tókst að taka núverandi stundaskrá.", "Broken hard wishes": "Brotnar skilyrðisóskir", - "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni" + "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni", + "Compare scenarios": "Bera saman sviðsmyndir", + "Scenarios to compare": "Sviðsmyndir til samanburðar", + "Choose two or three scenarios to compare them.": "Veldu tvær eða þrjár sviðsmyndir til að bera saman.", + "Measure": "Mælikvarði", + "Best": "Best", + "Lessons that differ": "Kennslustundir sem eru ólíkar", + "No lesson differs.": "Engin kennslustund er ólík.", + "Not placed": "Ekki raðað", + "{period} in {room}": "{period} í {room}", + "Lessons placed": "Raðaðar kennslustundir", + "Lessons without a place": "Kennslustundir án staðar", + "Clashes": "Árekstrar", + "Hard wishes broken": "Brotnar fastar óskir", + "Soft wishes broken": "Brotnar sveigjanlegar óskir", + "Soft wishes broken, weighted": "Brotnar sveigjanlegar óskir, vegnar", + "Free periods between lessons, all teachers": "Eyður, allir kennarar", + "Most free periods of one teacher": "Flestar eyður hjá einum kennara", + "Most lessons of one group on a day": "Flestar kennslustundir hjá einum hópi á dag", + "Room use": "Nýting stofa" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 3cafacd9..aff481e9 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Taka núverandi stundaskrá", "Could not take the current timetable.": "Ekki tókst að taka núverandi stundaskrá.", "Broken hard wishes": "Brotnar skilyrðisóskir", - "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni" + "Not on a period of the week grid": "Ekki á kennslustund í vikutöflunni", + "Compare scenarios": "Bera saman sviðsmyndir", + "Scenarios to compare": "Sviðsmyndir til samanburðar", + "Choose two or three scenarios to compare them.": "Veldu tvær eða þrjár sviðsmyndir til að bera saman.", + "Measure": "Mælikvarði", + "Best": "Best", + "Lessons that differ": "Kennslustundir sem eru ólíkar", + "No lesson differs.": "Engin kennslustund er ólík.", + "Not placed": "Ekki raðað", + "{period} in {room}": "{period} í {room}", + "Lessons placed": "Raðaðar kennslustundir", + "Lessons without a place": "Kennslustundir án staðar", + "Clashes": "Árekstrar", + "Hard wishes broken": "Brotnar fastar óskir", + "Soft wishes broken": "Brotnar sveigjanlegar óskir", + "Soft wishes broken, weighted": "Brotnar sveigjanlegar óskir, vegnar", + "Free periods between lessons, all teachers": "Eyður, allir kennarar", + "Most free periods of one teacher": "Flestar eyður hjá einum kennara", + "Most lessons of one group on a day": "Flestar kennslustundir hjá einum hópi á dag", + "Room use": "Nýting stofa" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index 5a64d509..db06f91a 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Prendi l'orario attuale", "Could not take the current timetable.": "Impossibile prendere l'orario attuale.", "Broken hard wishes": "Desideri vincolanti non rispettati", - "Not on a period of the week grid": "Non su un'ora della griglia settimanale" + "Not on a period of the week grid": "Non su un'ora della griglia settimanale", + "Compare scenarios": "Confronta scenari", + "Scenarios to compare": "Scenari da confrontare", + "Choose two or three scenarios to compare them.": "Scegli due o tre scenari per confrontarli.", + "Measure": "Misura", + "Best": "Migliore", + "Lessons that differ": "Lezioni che differiscono", + "No lesson differs.": "Nessuna lezione differisce.", + "Not placed": "Non collocata", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Lezioni collocate", + "Lessons without a place": "Lezioni senza posto", + "Clashes": "Conflitti", + "Hard wishes broken": "Desideri vincolanti non rispettati", + "Soft wishes broken": "Desideri flessibili non rispettati", + "Soft wishes broken, weighted": "Desideri flessibili non rispettati, ponderati", + "Free periods between lessons, all teachers": "Ore buche, tutti i docenti", + "Most free periods of one teacher": "Più ore buche di un docente", + "Most lessons of one group on a day": "Più lezioni di un gruppo in un giorno", + "Room use": "Uso delle aule" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 01efbd7c..5da6b277 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Prendi l'orario attuale", "Could not take the current timetable.": "Impossibile prendere l'orario attuale.", "Broken hard wishes": "Desideri vincolanti non rispettati", - "Not on a period of the week grid": "Non su un'ora della griglia settimanale" + "Not on a period of the week grid": "Non su un'ora della griglia settimanale", + "Compare scenarios": "Confronta scenari", + "Scenarios to compare": "Scenari da confrontare", + "Choose two or three scenarios to compare them.": "Scegli due o tre scenari per confrontarli.", + "Measure": "Misura", + "Best": "Migliore", + "Lessons that differ": "Lezioni che differiscono", + "No lesson differs.": "Nessuna lezione differisce.", + "Not placed": "Non collocata", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Lezioni collocate", + "Lessons without a place": "Lezioni senza posto", + "Clashes": "Conflitti", + "Hard wishes broken": "Desideri vincolanti non rispettati", + "Soft wishes broken": "Desideri flessibili non rispettati", + "Soft wishes broken, weighted": "Desideri flessibili non rispettati, ponderati", + "Free periods between lessons, all teachers": "Ore buche, tutti i docenti", + "Most free periods of one teacher": "Più ore buche di un docente", + "Most lessons of one group on a day": "Più lezioni di un gruppo in un giorno", + "Room use": "Uso delle aule" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 9229420a..01a195ff 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Aktuelle Stonneplang iwwerhuelen", "Could not take the current timetable.": "Den aktuelle Stonneplang konnt net iwwerholl ginn.", "Broken hard wishes": "Verletzt haart Wënsch", - "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter" + "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter", + "Compare scenarios": "Szenarie vergläichen", + "Scenarios to compare": "Szenarien zum Vergläichen", + "Choose two or three scenarios to compare them.": "Wielt zwee oder dräi Szenarien fir se ze vergläichen.", + "Measure": "Moossgréisst", + "Best": "Beschte Wäert", + "Lessons that differ": "Stonnen, déi sech ënnerscheeden", + "No lesson differs.": "Keng Stonn ënnerscheet sech.", + "Not placed": "Net placéiert", + "{period} in {room}": "{period} am {room}", + "Lessons placed": "Placéiert Stonnen", + "Lessons without a place": "Stonnen ouni Plaz", + "Clashes": "Konflikter", + "Hard wishes broken": "Verletzt fix Wënsch", + "Soft wishes broken": "Verletzt flexibel Wënsch", + "Soft wishes broken, weighted": "Verletzt flexibel Wënsch, gewiicht", + "Free periods between lessons, all teachers": "Fräistonnen, all Enseignanten", + "Most free periods of one teacher": "Meeschte Fräistonne vun engem Enseignant", + "Most lessons of one group on a day": "Meeschte Stonne vun enger Grupp un engem Dag", + "Room use": "Sallbenotzung" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 593f72d5..50221dd2 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Aktuelle Stonneplang iwwerhuelen", "Could not take the current timetable.": "Den aktuelle Stonneplang konnt net iwwerholl ginn.", "Broken hard wishes": "Verletzt haart Wënsch", - "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter" + "Not on a period of the week grid": "Net op enger Stonn vum Wochegitter", + "Compare scenarios": "Szenarie vergläichen", + "Scenarios to compare": "Szenarien zum Vergläichen", + "Choose two or three scenarios to compare them.": "Wielt zwee oder dräi Szenarien fir se ze vergläichen.", + "Measure": "Moossgréisst", + "Best": "Beschte Wäert", + "Lessons that differ": "Stonnen, déi sech ënnerscheeden", + "No lesson differs.": "Keng Stonn ënnerscheet sech.", + "Not placed": "Net placéiert", + "{period} in {room}": "{period} am {room}", + "Lessons placed": "Placéiert Stonnen", + "Lessons without a place": "Stonnen ouni Plaz", + "Clashes": "Konflikter", + "Hard wishes broken": "Verletzt fix Wënsch", + "Soft wishes broken": "Verletzt flexibel Wënsch", + "Soft wishes broken, weighted": "Verletzt flexibel Wënsch, gewiicht", + "Free periods between lessons, all teachers": "Fräistonnen, all Enseignanten", + "Most free periods of one teacher": "Meeschte Fräistonne vun engem Enseignant", + "Most lessons of one group on a day": "Meeschte Stonne vun enger Grupp un engem Dag", + "Room use": "Sallbenotzung" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 8236b964..0906223c 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Perimti dabartinį tvarkaraštį", "Could not take the current timetable.": "Nepavyko perimti dabartinio tvarkaraščio.", "Broken hard wishes": "Pažeisti privalomi pageidavimai", - "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje" + "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje", + "Compare scenarios": "Palyginti scenarijus", + "Scenarios to compare": "Lyginami scenarijai", + "Choose two or three scenarios to compare them.": "Pasirinkite du ar tris scenarijus palyginti.", + "Measure": "Rodiklis", + "Best": "Geriausias", + "Lessons that differ": "Besiskiriančios pamokos", + "No lesson differs.": "Jokia pamoka nesiskiria.", + "Not placed": "Neišdėstyta", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Išdėstytos pamokos", + "Lessons without a place": "Pamokos be vietos", + "Clashes": "Konfliktai", + "Hard wishes broken": "Pažeisti privalomi pageidavimai", + "Soft wishes broken": "Pažeisti lankstūs pageidavimai", + "Soft wishes broken, weighted": "Pažeisti lankstūs pageidavimai, pasverti", + "Free periods between lessons, all teachers": "Langai, visi mokytojai", + "Most free periods of one teacher": "Daugiausia vieno mokytojo langų", + "Most lessons of one group on a day": "Daugiausia vienos grupės pamokų per dieną", + "Room use": "Patalpų naudojimas" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index d598014a..eb822b2c 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Perimti dabartinį tvarkaraštį", "Could not take the current timetable.": "Nepavyko perimti dabartinio tvarkaraščio.", "Broken hard wishes": "Pažeisti privalomi pageidavimai", - "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje" + "Not on a period of the week grid": "Ne savaitės tinklelio pamokoje", + "Compare scenarios": "Palyginti scenarijus", + "Scenarios to compare": "Lyginami scenarijai", + "Choose two or three scenarios to compare them.": "Pasirinkite du ar tris scenarijus palyginti.", + "Measure": "Rodiklis", + "Best": "Geriausias", + "Lessons that differ": "Besiskiriančios pamokos", + "No lesson differs.": "Jokia pamoka nesiskiria.", + "Not placed": "Neišdėstyta", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Išdėstytos pamokos", + "Lessons without a place": "Pamokos be vietos", + "Clashes": "Konfliktai", + "Hard wishes broken": "Pažeisti privalomi pageidavimai", + "Soft wishes broken": "Pažeisti lankstūs pageidavimai", + "Soft wishes broken, weighted": "Pažeisti lankstūs pageidavimai, pasverti", + "Free periods between lessons, all teachers": "Langai, visi mokytojai", + "Most free periods of one teacher": "Daugiausia vieno mokytojo langų", + "Most lessons of one group on a day": "Daugiausia vienos grupės pamokų per dieną", + "Room use": "Patalpų naudojimas" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index da07718a..2ab47307 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Pārņemt pašreizējo stundu sarakstu", "Could not take the current timetable.": "Neizdevās pārņemt pašreizējo stundu sarakstu.", "Broken hard wishes": "Pārkāptās obligātās vēlmes", - "Not on a period of the week grid": "Nav nedēļas režģa stundā" + "Not on a period of the week grid": "Nav nedēļas režģa stundā", + "Compare scenarios": "Salīdzināt scenārijus", + "Scenarios to compare": "Salīdzināmie scenāriji", + "Choose two or three scenarios to compare them.": "Izvēlieties divus vai trīs scenārijus salīdzināšanai.", + "Measure": "Rādītājs", + "Best": "Labākais", + "Lessons that differ": "Atšķirīgās stundas", + "No lesson differs.": "Neviena stunda neatšķiras.", + "Not placed": "Nav izvietota", + "{period} in {room}": "{period} telpā {room}", + "Lessons placed": "Izvietotās stundas", + "Lessons without a place": "Stundas bez vietas", + "Clashes": "Konflikti", + "Hard wishes broken": "Pārkāptās obligātās vēlmes", + "Soft wishes broken": "Pārkāptās elastīgās vēlmes", + "Soft wishes broken, weighted": "Pārkāptās elastīgās vēlmes, svērtas", + "Free periods between lessons, all teachers": "Brīvstundas, visi skolotāji", + "Most free periods of one teacher": "Viena skolotāja visvairāk brīvstundu", + "Most lessons of one group on a day": "Vienas grupas visvairāk stundu dienā", + "Room use": "Telpu izmantojums" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 10fee2e3..69441905 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Pārņemt pašreizējo stundu sarakstu", "Could not take the current timetable.": "Neizdevās pārņemt pašreizējo stundu sarakstu.", "Broken hard wishes": "Pārkāptās obligātās vēlmes", - "Not on a period of the week grid": "Nav nedēļas režģa stundā" + "Not on a period of the week grid": "Nav nedēļas režģa stundā", + "Compare scenarios": "Salīdzināt scenārijus", + "Scenarios to compare": "Salīdzināmie scenāriji", + "Choose two or three scenarios to compare them.": "Izvēlieties divus vai trīs scenārijus salīdzināšanai.", + "Measure": "Rādītājs", + "Best": "Labākais", + "Lessons that differ": "Atšķirīgās stundas", + "No lesson differs.": "Neviena stunda neatšķiras.", + "Not placed": "Nav izvietota", + "{period} in {room}": "{period} telpā {room}", + "Lessons placed": "Izvietotās stundas", + "Lessons without a place": "Stundas bez vietas", + "Clashes": "Konflikti", + "Hard wishes broken": "Pārkāptās obligātās vēlmes", + "Soft wishes broken": "Pārkāptās elastīgās vēlmes", + "Soft wishes broken, weighted": "Pārkāptās elastīgās vēlmes, svērtas", + "Free periods between lessons, all teachers": "Brīvstundas, visi skolotāji", + "Most free periods of one teacher": "Viena skolotāja visvairāk brīvstundu", + "Most lessons of one group on a day": "Vienas grupas visvairāk stundu dienā", + "Room use": "Telpu izmantojums" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 3e2b44c0..925ca846 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Преземи го тековниот распоред", "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", "Broken hard wishes": "Прекршени задолжителни желби", - "Not on a period of the week grid": "Не е на час од неделната мрежа" + "Not on a period of the week grid": "Не е на час од неделната мрежа", + "Compare scenarios": "Спореди сценарија", + "Scenarios to compare": "Сценарија за споредба", + "Choose two or three scenarios to compare them.": "Изберете две или три сценарија за споредба.", + "Measure": "Мерка", + "Best": "Најдобро", + "Lessons that differ": "Часови што се разликуваат", + "No lesson differs.": "Ниту еден час не се разликува.", + "Not placed": "Не е сместен", + "{period} in {room}": "{period} во {room}", + "Lessons placed": "Сместени часови", + "Lessons without a place": "Часови без место", + "Clashes": "Судири", + "Hard wishes broken": "Прекршени задолжителни желби", + "Soft wishes broken": "Прекршени флексибилни желби", + "Soft wishes broken, weighted": "Прекршени флексибилни желби, пондерирани", + "Free periods between lessons, all teachers": "Слободни часови, сите наставници", + "Most free periods of one teacher": "Најмногу слободни часови на еден наставник", + "Most lessons of one group on a day": "Најмногу часови на една група во ден", + "Room use": "Користење на простории" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index ef776340..eeb7d16b 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Преземи го тековниот распоред", "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", "Broken hard wishes": "Прекршени задолжителни желби", - "Not on a period of the week grid": "Не е на час од неделната мрежа" + "Not on a period of the week grid": "Не е на час од неделната мрежа", + "Compare scenarios": "Спореди сценарија", + "Scenarios to compare": "Сценарија за споредба", + "Choose two or three scenarios to compare them.": "Изберете две или три сценарија за споредба.", + "Measure": "Мерка", + "Best": "Најдобро", + "Lessons that differ": "Часови што се разликуваат", + "No lesson differs.": "Ниту еден час не се разликува.", + "Not placed": "Не е сместен", + "{period} in {room}": "{period} во {room}", + "Lessons placed": "Сместени часови", + "Lessons without a place": "Часови без место", + "Clashes": "Судири", + "Hard wishes broken": "Прекршени задолжителни желби", + "Soft wishes broken": "Прекршени флексибилни желби", + "Soft wishes broken, weighted": "Прекршени флексибилни желби, пондерирани", + "Free periods between lessons, all teachers": "Слободни часови, сите наставници", + "Most free periods of one teacher": "Најмногу слободни часови на еден наставник", + "Most lessons of one group on a day": "Најмногу часови на една група во ден", + "Room use": "Користење на простории" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index 76d00545..3ebaffaf 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Ħu l-orarju attwali", "Could not take the current timetable.": "Ma setax jittieħed l-orarju attwali.", "Broken hard wishes": "Xewqat obbligatorji miksura", - "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa" + "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa", + "Compare scenarios": "Qabbel ix-xenarji", + "Scenarios to compare": "Xenarji biex jitqabblu", + "Choose two or three scenarios to compare them.": "Agħżel żewġ jew tliet xenarji biex tqabbilhom.", + "Measure": "Kejl", + "Best": "L-aħjar", + "Lessons that differ": "Lezzjonijiet li jvarjaw", + "No lesson differs.": "L-ebda lezzjoni ma tvarja.", + "Not placed": "Mhux imqiegħda", + "{period} in {room}": "{period} f'{room}", + "Lessons placed": "Lezzjonijiet imqiegħda", + "Lessons without a place": "Lezzjonijiet mingħajr post", + "Clashes": "Kunflitti", + "Hard wishes broken": "Xewqat iebsa miksura", + "Soft wishes broken": "Xewqat ratba miksura", + "Soft wishes broken, weighted": "Xewqat ratba miksura, peżati", + "Free periods between lessons, all teachers": "Perjodi ħielsa, l-għalliema kollha", + "Most free periods of one teacher": "L-aktar perjodi ħielsa ta' għalliem wieħed", + "Most lessons of one group on a day": "L-aktar lezzjonijiet ta' grupp wieħed f'ġurnata", + "Room use": "Użu tal-kmamar" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 711d6dc6..0923b884 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Ħu l-orarju attwali", "Could not take the current timetable.": "Ma setax jittieħed l-orarju attwali.", "Broken hard wishes": "Xewqat obbligatorji miksura", - "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa" + "Not on a period of the week grid": "Mhux f'lezzjoni tal-grilja tal-ġimgħa", + "Compare scenarios": "Qabbel ix-xenarji", + "Scenarios to compare": "Xenarji biex jitqabblu", + "Choose two or three scenarios to compare them.": "Agħżel żewġ jew tliet xenarji biex tqabbilhom.", + "Measure": "Kejl", + "Best": "L-aħjar", + "Lessons that differ": "Lezzjonijiet li jvarjaw", + "No lesson differs.": "L-ebda lezzjoni ma tvarja.", + "Not placed": "Mhux imqiegħda", + "{period} in {room}": "{period} f'{room}", + "Lessons placed": "Lezzjonijiet imqiegħda", + "Lessons without a place": "Lezzjonijiet mingħajr post", + "Clashes": "Kunflitti", + "Hard wishes broken": "Xewqat iebsa miksura", + "Soft wishes broken": "Xewqat ratba miksura", + "Soft wishes broken, weighted": "Xewqat ratba miksura, peżati", + "Free periods between lessons, all teachers": "Perjodi ħielsa, l-għalliema kollha", + "Most free periods of one teacher": "L-aktar perjodi ħielsa ta' għalliem wieħed", + "Most lessons of one group on a day": "L-aktar lezzjonijiet ta' grupp wieħed f'ġurnata", + "Room use": "Użu tal-kmamar" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index e087cae2..ec424e05 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Bruk den gjeldende timeplanen", "Could not take the current timetable.": "Kunne ikke hente den gjeldende timeplanen.", "Broken hard wishes": "Brutte harde ønsker", - "Not on a period of the week grid": "Ikke på en time i ukerutenettet" + "Not on a period of the week grid": "Ikke på en time i ukerutenettet", + "Compare scenarios": "Sammenlign scenarier", + "Scenarios to compare": "Scenarier å sammenligne", + "Choose two or three scenarios to compare them.": "Velg to eller tre scenarier for å sammenligne dem.", + "Measure": "Mål", + "Best": "Best", + "Lessons that differ": "Timer som er ulike", + "No lesson differs.": "Ingen timer er ulike.", + "Not placed": "Ikke plassert", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Plasserte timer", + "Lessons without a place": "Timer uten plass", + "Clashes": "Kollisjoner", + "Hard wishes broken": "Brutte harde ønsker", + "Soft wishes broken": "Brutte myke ønsker", + "Soft wishes broken, weighted": "Brutte myke ønsker, vektet", + "Free periods between lessons, all teachers": "Hulltimer, alle lærere", + "Most free periods of one teacher": "Flest hulltimer for én lærer", + "Most lessons of one group on a day": "Flest timer for én gruppe på en dag", + "Room use": "Rombruk" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 823a9ce6..952b1532 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Bruk den gjeldende timeplanen", "Could not take the current timetable.": "Kunne ikke hente den gjeldende timeplanen.", "Broken hard wishes": "Brutte harde ønsker", - "Not on a period of the week grid": "Ikke på en time i ukerutenettet" + "Not on a period of the week grid": "Ikke på en time i ukerutenettet", + "Compare scenarios": "Sammenlign scenarier", + "Scenarios to compare": "Scenarier å sammenligne", + "Choose two or three scenarios to compare them.": "Velg to eller tre scenarier for å sammenligne dem.", + "Measure": "Mål", + "Best": "Best", + "Lessons that differ": "Timer som er ulike", + "No lesson differs.": "Ingen timer er ulike.", + "Not placed": "Ikke plassert", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Plasserte timer", + "Lessons without a place": "Timer uten plass", + "Clashes": "Kollisjoner", + "Hard wishes broken": "Brutte harde ønsker", + "Soft wishes broken": "Brutte myke ønsker", + "Soft wishes broken, weighted": "Brutte myke ønsker, vektet", + "Free periods between lessons, all teachers": "Hulltimer, alle lærere", + "Most free periods of one teacher": "Flest hulltimer for én lærer", + "Most lessons of one group on a day": "Flest timer for én gruppe på en dag", + "Room use": "Rombruk" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 688e1b27..fe670ef9 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1062,7 +1062,26 @@ OC.L10N.register( "Take the current timetable": "Huidig rooster overnemen", "Could not take the current timetable.": "Kon het huidige rooster niet overnemen.", "Broken hard wishes": "Geschonden harde wensen", - "Not on a period of the week grid": "Niet op een lesuur van het weekrooster" + "Not on a period of the week grid": "Niet op een lesuur van het weekrooster", + "Compare scenarios": "Scenario's vergelijken", + "Scenarios to compare": "Te vergelijken scenario's", + "Choose two or three scenarios to compare them.": "Kies twee of drie scenario's om ze te vergelijken.", + "Measure": "Meting", + "Best": "Beste", + "Lessons that differ": "Lessen die verschillen", + "No lesson differs.": "Geen les verschilt.", + "Not placed": "Niet geplaatst", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Geplaatste lessen", + "Lessons without a place": "Lessen zonder plek", + "Clashes": "Botsingen", + "Hard wishes broken": "Geschonden harde wensen", + "Soft wishes broken": "Geschonden zachte wensen", + "Soft wishes broken, weighted": "Geschonden zachte wensen, gewogen", + "Free periods between lessons, all teachers": "Tussenuren, alle docenten", + "Most free periods of one teacher": "Meeste tussenuren van één docent", + "Most lessons of one group on a day": "Meeste lessen van één groep op een dag", + "Room use": "Lokaalgebruik" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index f8476275..204a9b4c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1061,7 +1061,26 @@ "Take the current timetable": "Huidig rooster overnemen", "Could not take the current timetable.": "Kon het huidige rooster niet overnemen.", "Broken hard wishes": "Geschonden harde wensen", - "Not on a period of the week grid": "Niet op een lesuur van het weekrooster" + "Not on a period of the week grid": "Niet op een lesuur van het weekrooster", + "Compare scenarios": "Scenario's vergelijken", + "Scenarios to compare": "Te vergelijken scenario's", + "Choose two or three scenarios to compare them.": "Kies twee of drie scenario's om ze te vergelijken.", + "Measure": "Meting", + "Best": "Beste", + "Lessons that differ": "Lessen die verschillen", + "No lesson differs.": "Geen les verschilt.", + "Not placed": "Niet geplaatst", + "{period} in {room}": "{period} in {room}", + "Lessons placed": "Geplaatste lessen", + "Lessons without a place": "Lessen zonder plek", + "Clashes": "Botsingen", + "Hard wishes broken": "Geschonden harde wensen", + "Soft wishes broken": "Geschonden zachte wensen", + "Soft wishes broken, weighted": "Geschonden zachte wensen, gewogen", + "Free periods between lessons, all teachers": "Tussenuren, alle docenten", + "Most free periods of one teacher": "Meeste tussenuren van één docent", + "Most lessons of one group on a day": "Meeste lessen van één groep op een dag", + "Room use": "Lokaalgebruik" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 8d405cd1..fe6d4503 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Przejmij obecny plan lekcji", "Could not take the current timetable.": "Nie udało się przejąć obecnego planu lekcji.", "Broken hard wishes": "Naruszone twarde życzenia", - "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej" + "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej", + "Compare scenarios": "Porównaj scenariusze", + "Scenarios to compare": "Scenariusze do porównania", + "Choose two or three scenarios to compare them.": "Wybierz dwa lub trzy scenariusze, aby je porównać.", + "Measure": "Miara", + "Best": "Najlepsza", + "Lessons that differ": "Lekcje, które się różnią", + "No lesson differs.": "Żadna lekcja się nie różni.", + "Not placed": "Nieumieszczona", + "{period} in {room}": "{period} w {room}", + "Lessons placed": "Umieszczone lekcje", + "Lessons without a place": "Lekcje bez miejsca", + "Clashes": "Konflikty", + "Hard wishes broken": "Niedotrzymane twarde życzenia", + "Soft wishes broken": "Niedotrzymane miękkie życzenia", + "Soft wishes broken, weighted": "Niedotrzymane miękkie życzenia, ważone", + "Free periods between lessons, all teachers": "Okienka, wszyscy nauczyciele", + "Most free periods of one teacher": "Najwięcej okienek jednego nauczyciela", + "Most lessons of one group on a day": "Najwięcej lekcji jednej grupy w dniu", + "Room use": "Wykorzystanie sal" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index 94445d51..b680fc86 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Przejmij obecny plan lekcji", "Could not take the current timetable.": "Nie udało się przejąć obecnego planu lekcji.", "Broken hard wishes": "Naruszone twarde życzenia", - "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej" + "Not on a period of the week grid": "Nie na godzinie siatki tygodniowej", + "Compare scenarios": "Porównaj scenariusze", + "Scenarios to compare": "Scenariusze do porównania", + "Choose two or three scenarios to compare them.": "Wybierz dwa lub trzy scenariusze, aby je porównać.", + "Measure": "Miara", + "Best": "Najlepsza", + "Lessons that differ": "Lekcje, które się różnią", + "No lesson differs.": "Żadna lekcja się nie różni.", + "Not placed": "Nieumieszczona", + "{period} in {room}": "{period} w {room}", + "Lessons placed": "Umieszczone lekcje", + "Lessons without a place": "Lekcje bez miejsca", + "Clashes": "Konflikty", + "Hard wishes broken": "Niedotrzymane twarde życzenia", + "Soft wishes broken": "Niedotrzymane miękkie życzenia", + "Soft wishes broken, weighted": "Niedotrzymane miękkie życzenia, ważone", + "Free periods between lessons, all teachers": "Okienka, wszyscy nauczyciele", + "Most free periods of one teacher": "Najwięcej okienek jednego nauczyciela", + "Most lessons of one group on a day": "Najwięcej lekcji jednej grupy w dniu", + "Room use": "Wykorzystanie sal" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index 819aa0e5..d4bfef38 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Usar o horário atual", "Could not take the current timetable.": "Não foi possível usar o horário atual.", "Broken hard wishes": "Desejos obrigatórios não cumpridos", - "Not on a period of the week grid": "Não está num tempo da grelha semanal" + "Not on a period of the week grid": "Não está num tempo da grelha semanal", + "Compare scenarios": "Comparar cenários", + "Scenarios to compare": "Cenários a comparar", + "Choose two or three scenarios to compare them.": "Escolha dois ou três cenários para os comparar.", + "Measure": "Medida", + "Best": "Melhor", + "Lessons that differ": "Aulas que diferem", + "No lesson differs.": "Nenhuma aula difere.", + "Not placed": "Não colocada", + "{period} in {room}": "{period} em {room}", + "Lessons placed": "Aulas colocadas", + "Lessons without a place": "Aulas sem lugar", + "Clashes": "Conflitos", + "Hard wishes broken": "Desejos rígidos não cumpridos", + "Soft wishes broken": "Desejos flexíveis não cumpridos", + "Soft wishes broken, weighted": "Desejos flexíveis não cumpridos, ponderados", + "Free periods between lessons, all teachers": "Furos, todos os professores", + "Most free periods of one teacher": "Mais furos de um professor", + "Most lessons of one group on a day": "Mais aulas de um grupo num dia", + "Room use": "Ocupação das salas" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 71a7f95f..ae68e82f 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Usar o horário atual", "Could not take the current timetable.": "Não foi possível usar o horário atual.", "Broken hard wishes": "Desejos obrigatórios não cumpridos", - "Not on a period of the week grid": "Não está num tempo da grelha semanal" + "Not on a period of the week grid": "Não está num tempo da grelha semanal", + "Compare scenarios": "Comparar cenários", + "Scenarios to compare": "Cenários a comparar", + "Choose two or three scenarios to compare them.": "Escolha dois ou três cenários para os comparar.", + "Measure": "Medida", + "Best": "Melhor", + "Lessons that differ": "Aulas que diferem", + "No lesson differs.": "Nenhuma aula difere.", + "Not placed": "Não colocada", + "{period} in {room}": "{period} em {room}", + "Lessons placed": "Aulas colocadas", + "Lessons without a place": "Aulas sem lugar", + "Clashes": "Conflitos", + "Hard wishes broken": "Desejos rígidos não cumpridos", + "Soft wishes broken": "Desejos flexíveis não cumpridos", + "Soft wishes broken, weighted": "Desejos flexíveis não cumpridos, ponderados", + "Free periods between lessons, all teachers": "Furos, todos os professores", + "Most free periods of one teacher": "Mais furos de um professor", + "Most lessons of one group on a day": "Mais aulas de um grupo num dia", + "Room use": "Ocupação das salas" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index f5a1ea1d..0ba72f9b 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Surpigliar l'urari actual", "Could not take the current timetable.": "Impussibel da surpigliar l'urari actual.", "Broken hard wishes": "Giavischs obligatorics violads", - "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna" + "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna", + "Compare scenarios": "Cumparegliar scenaris", + "Scenarios to compare": "Scenaris da cumparegliar", + "Choose two or three scenarios to compare them.": "Tscherni dus u trais scenaris per als cumparegliar.", + "Measure": "Mesira", + "Best": "Il meglier", + "Lessons that differ": "Lecziuns che sa distinguan", + "No lesson differs.": "Nagina lecziun na sa distingua.", + "Not placed": "Betg plazzà", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Lecziuns plazzadas", + "Lessons without a place": "Lecziuns senza plaz", + "Clashes": "Conflicts", + "Hard wishes broken": "Giavischs obligatorics violads", + "Soft wishes broken": "Giavischs flexibels violads", + "Soft wishes broken, weighted": "Giavischs flexibels violads, ponderads", + "Free periods between lessons, all teachers": "Uras libras, tut ils magisters", + "Most free periods of one teacher": "Il pli bler uras libras d'in magister", + "Most lessons of one group on a day": "Il pli bler lecziuns d'ina gruppa en in di", + "Room use": "Utilisaziun da las stanzas" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index f706c75c..e180f576 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Surpigliar l'urari actual", "Could not take the current timetable.": "Impussibel da surpigliar l'urari actual.", "Broken hard wishes": "Giavischs obligatorics violads", - "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna" + "Not on a period of the week grid": "Betg sin ina lecziun da la grilla da l'emna", + "Compare scenarios": "Cumparegliar scenaris", + "Scenarios to compare": "Scenaris da cumparegliar", + "Choose two or three scenarios to compare them.": "Tscherni dus u trais scenaris per als cumparegliar.", + "Measure": "Mesira", + "Best": "Il meglier", + "Lessons that differ": "Lecziuns che sa distinguan", + "No lesson differs.": "Nagina lecziun na sa distingua.", + "Not placed": "Betg plazzà", + "{period} in {room}": "{period} en {room}", + "Lessons placed": "Lecziuns plazzadas", + "Lessons without a place": "Lecziuns senza plaz", + "Clashes": "Conflicts", + "Hard wishes broken": "Giavischs obligatorics violads", + "Soft wishes broken": "Giavischs flexibels violads", + "Soft wishes broken, weighted": "Giavischs flexibels violads, ponderads", + "Free periods between lessons, all teachers": "Uras libras, tut ils magisters", + "Most free periods of one teacher": "Il pli bler uras libras d'in magister", + "Most lessons of one group on a day": "Il pli bler lecziuns d'ina gruppa en in di", + "Room use": "Utilisaziun da las stanzas" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 73034a4b..34294665 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Preia orarul actual", "Could not take the current timetable.": "Nu s-a putut prelua orarul actual.", "Broken hard wishes": "Dorințe obligatorii încălcate", - "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală" + "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală", + "Compare scenarios": "Compară scenarii", + "Scenarios to compare": "Scenarii de comparat", + "Choose two or three scenarios to compare them.": "Alegeți două sau trei scenarii pentru a le compara.", + "Measure": "Măsură", + "Best": "Cel mai bun", + "Lessons that differ": "Ore care diferă", + "No lesson differs.": "Nicio oră nu diferă.", + "Not placed": "Neplasată", + "{period} in {room}": "{period} în {room}", + "Lessons placed": "Ore plasate", + "Lessons without a place": "Ore fără loc", + "Clashes": "Conflicte", + "Hard wishes broken": "Dorințe stricte încălcate", + "Soft wishes broken": "Dorințe flexibile încălcate", + "Soft wishes broken, weighted": "Dorințe flexibile încălcate, ponderate", + "Free periods between lessons, all teachers": "Ferestre, toți profesorii", + "Most free periods of one teacher": "Cele mai multe ferestre ale unui profesor", + "Most lessons of one group on a day": "Cele mai multe ore ale unei grupe într-o zi", + "Room use": "Utilizarea sălilor" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index e16aa689..c22ef28d 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Preia orarul actual", "Could not take the current timetable.": "Nu s-a putut prelua orarul actual.", "Broken hard wishes": "Dorințe obligatorii încălcate", - "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală" + "Not on a period of the week grid": "Nu este pe o oră din grila săptămânală", + "Compare scenarios": "Compară scenarii", + "Scenarios to compare": "Scenarii de comparat", + "Choose two or three scenarios to compare them.": "Alegeți două sau trei scenarii pentru a le compara.", + "Measure": "Măsură", + "Best": "Cel mai bun", + "Lessons that differ": "Ore care diferă", + "No lesson differs.": "Nicio oră nu diferă.", + "Not placed": "Neplasată", + "{period} in {room}": "{period} în {room}", + "Lessons placed": "Ore plasate", + "Lessons without a place": "Ore fără loc", + "Clashes": "Conflicte", + "Hard wishes broken": "Dorințe stricte încălcate", + "Soft wishes broken": "Dorințe flexibile încălcate", + "Soft wishes broken, weighted": "Dorințe flexibile încălcate, ponderate", + "Free periods between lessons, all teachers": "Ferestre, toți profesorii", + "Most free periods of one teacher": "Cele mai multe ferestre ale unui profesor", + "Most lessons of one group on a day": "Cele mai multe ore ale unei grupe într-o zi", + "Room use": "Utilizarea sălilor" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index 135d18f8..31be706c 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Взять текущее расписание", "Could not take the current timetable.": "Не удалось взять текущее расписание.", "Broken hard wishes": "Нарушенные обязательные пожелания", - "Not on a period of the week grid": "Не на уроке недельной сетки" + "Not on a period of the week grid": "Не на уроке недельной сетки", + "Compare scenarios": "Сравнить сценарии", + "Scenarios to compare": "Сценарии для сравнения", + "Choose two or three scenarios to compare them.": "Выберите два или три сценария, чтобы сравнить их.", + "Measure": "Показатель", + "Best": "Лучший", + "Lessons that differ": "Уроки, которые отличаются", + "No lesson differs.": "Ни один урок не отличается.", + "Not placed": "Не размещён", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Размещённые уроки", + "Lessons without a place": "Уроки без места", + "Clashes": "Конфликты", + "Hard wishes broken": "Нарушенные обязательные пожелания", + "Soft wishes broken": "Нарушенные гибкие пожелания", + "Soft wishes broken, weighted": "Нарушенные гибкие пожелания, взвешенные", + "Free periods between lessons, all teachers": "Окна, все учителя", + "Most free periods of one teacher": "Больше всего окон у одного учителя", + "Most lessons of one group on a day": "Больше всего уроков одной группы за день", + "Room use": "Использование кабинетов" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 861a0245..9f45e2c6 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Взять текущее расписание", "Could not take the current timetable.": "Не удалось взять текущее расписание.", "Broken hard wishes": "Нарушенные обязательные пожелания", - "Not on a period of the week grid": "Не на уроке недельной сетки" + "Not on a period of the week grid": "Не на уроке недельной сетки", + "Compare scenarios": "Сравнить сценарии", + "Scenarios to compare": "Сценарии для сравнения", + "Choose two or three scenarios to compare them.": "Выберите два или три сценария, чтобы сравнить их.", + "Measure": "Показатель", + "Best": "Лучший", + "Lessons that differ": "Уроки, которые отличаются", + "No lesson differs.": "Ни один урок не отличается.", + "Not placed": "Не размещён", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Размещённые уроки", + "Lessons without a place": "Уроки без места", + "Clashes": "Конфликты", + "Hard wishes broken": "Нарушенные обязательные пожелания", + "Soft wishes broken": "Нарушенные гибкие пожелания", + "Soft wishes broken, weighted": "Нарушенные гибкие пожелания, взвешенные", + "Free periods between lessons, all teachers": "Окна, все учителя", + "Most free periods of one teacher": "Больше всего окон у одного учителя", + "Most lessons of one group on a day": "Больше всего уроков одной группы за день", + "Room use": "Использование кабинетов" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index 79bfa351..f29ba0d0 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Prevziať aktuálny rozvrh", "Could not take the current timetable.": "Aktuálny rozvrh sa nepodarilo prevziať.", "Broken hard wishes": "Porušené záväzné priania", - "Not on a period of the week grid": "Nie je na hodine týždennej mriežky" + "Not on a period of the week grid": "Nie je na hodine týždennej mriežky", + "Compare scenarios": "Porovnať scenáre", + "Scenarios to compare": "Scenáre na porovnanie", + "Choose two or three scenarios to compare them.": "Vyberte dva alebo tri scenáre na porovnanie.", + "Measure": "Ukazovateľ", + "Best": "Najlepšie", + "Lessons that differ": "Hodiny, ktoré sa líšia", + "No lesson differs.": "Žiadna hodina sa nelíši.", + "Not placed": "Neumiestnené", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umiestnené hodiny", + "Lessons without a place": "Hodiny bez miesta", + "Clashes": "Kolízie", + "Hard wishes broken": "Porušené pevné želania", + "Soft wishes broken": "Porušené mäkké želania", + "Soft wishes broken, weighted": "Porušené mäkké želania, vážené", + "Free periods between lessons, all teachers": "Voľné hodiny, všetci učitelia", + "Most free periods of one teacher": "Najviac voľných hodín jedného učiteľa", + "Most lessons of one group on a day": "Najviac hodín jednej skupiny za deň", + "Room use": "Využitie učební" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 11b2b153..e188baeb 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Prevziať aktuálny rozvrh", "Could not take the current timetable.": "Aktuálny rozvrh sa nepodarilo prevziať.", "Broken hard wishes": "Porušené záväzné priania", - "Not on a period of the week grid": "Nie je na hodine týždennej mriežky" + "Not on a period of the week grid": "Nie je na hodine týždennej mriežky", + "Compare scenarios": "Porovnať scenáre", + "Scenarios to compare": "Scenáre na porovnanie", + "Choose two or three scenarios to compare them.": "Vyberte dva alebo tri scenáre na porovnanie.", + "Measure": "Ukazovateľ", + "Best": "Najlepšie", + "Lessons that differ": "Hodiny, ktoré sa líšia", + "No lesson differs.": "Žiadna hodina sa nelíši.", + "Not placed": "Neumiestnené", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umiestnené hodiny", + "Lessons without a place": "Hodiny bez miesta", + "Clashes": "Kolízie", + "Hard wishes broken": "Porušené pevné želania", + "Soft wishes broken": "Porušené mäkké želania", + "Soft wishes broken, weighted": "Porušené mäkké želania, vážené", + "Free periods between lessons, all teachers": "Voľné hodiny, všetci učitelia", + "Most free periods of one teacher": "Najviac voľných hodín jedného učiteľa", + "Most lessons of one group on a day": "Najviac hodín jednej skupiny za deň", + "Room use": "Využitie učební" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index 89f7809b..d9334f8f 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Prevzemi trenutni urnik", "Could not take the current timetable.": "Trenutnega urnika ni bilo mogoče prevzeti.", "Broken hard wishes": "Kršene obvezne želje", - "Not on a period of the week grid": "Ni na uri tedenske mreže" + "Not on a period of the week grid": "Ni na uri tedenske mreže", + "Compare scenarios": "Primerjaj scenarije", + "Scenarios to compare": "Scenariji za primerjavo", + "Choose two or three scenarios to compare them.": "Izberite dva ali tri scenarije za primerjavo.", + "Measure": "Merilo", + "Best": "Najboljše", + "Lessons that differ": "Ure, ki se razlikujejo", + "No lesson differs.": "Nobena ura se ne razlikuje.", + "Not placed": "Ni umeščeno", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umeščene ure", + "Lessons without a place": "Ure brez mesta", + "Clashes": "Spori", + "Hard wishes broken": "Kršene obvezne želje", + "Soft wishes broken": "Kršene prožne želje", + "Soft wishes broken, weighted": "Kršene prožne želje, utežene", + "Free periods between lessons, all teachers": "Proste ure, vsi učitelji", + "Most free periods of one teacher": "Največ prostih ur enega učitelja", + "Most lessons of one group on a day": "Največ ur ene skupine na dan", + "Room use": "Raba učilnic" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 5fba1890..e84ddee4 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Prevzemi trenutni urnik", "Could not take the current timetable.": "Trenutnega urnika ni bilo mogoče prevzeti.", "Broken hard wishes": "Kršene obvezne želje", - "Not on a period of the week grid": "Ni na uri tedenske mreže" + "Not on a period of the week grid": "Ni na uri tedenske mreže", + "Compare scenarios": "Primerjaj scenarije", + "Scenarios to compare": "Scenariji za primerjavo", + "Choose two or three scenarios to compare them.": "Izberite dva ali tri scenarije za primerjavo.", + "Measure": "Merilo", + "Best": "Najboljše", + "Lessons that differ": "Ure, ki se razlikujejo", + "No lesson differs.": "Nobena ura se ne razlikuje.", + "Not placed": "Ni umeščeno", + "{period} in {room}": "{period} v {room}", + "Lessons placed": "Umeščene ure", + "Lessons without a place": "Ure brez mesta", + "Clashes": "Spori", + "Hard wishes broken": "Kršene obvezne želje", + "Soft wishes broken": "Kršene prožne želje", + "Soft wishes broken, weighted": "Kršene prožne želje, utežene", + "Free periods between lessons, all teachers": "Proste ure, vsi učitelji", + "Most free periods of one teacher": "Največ prostih ur enega učitelja", + "Most lessons of one group on a day": "Največ ur ene skupine na dan", + "Room use": "Raba učilnic" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 88059549..7ebb9ea4 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Merr orarin aktual", "Could not take the current timetable.": "Orari aktual nuk u mor dot.", "Broken hard wishes": "Dëshira të detyrueshme të shkelura", - "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore" + "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore", + "Compare scenarios": "Krahaso skenarët", + "Scenarios to compare": "Skenarët për krahasim", + "Choose two or three scenarios to compare them.": "Zgjidhni dy ose tre skenarë për t'i krahasuar.", + "Measure": "Masa", + "Best": "Më i miri", + "Lessons that differ": "Orë mësimi që ndryshojnë", + "No lesson differs.": "Asnjë orë mësimi nuk ndryshon.", + "Not placed": "E pavendosur", + "{period} in {room}": "{period} në {room}", + "Lessons placed": "Orë mësimi të vendosura", + "Lessons without a place": "Orë mësimi pa vend", + "Clashes": "Përplasje", + "Hard wishes broken": "Dëshira të detyrueshme të shkelura", + "Soft wishes broken": "Dëshira të buta të shkelura", + "Soft wishes broken, weighted": "Dëshira të buta të shkelura, të peshuara", + "Free periods between lessons, all teachers": "Orë bosh, të gjithë mësuesit", + "Most free periods of one teacher": "Më shumë orë bosh të një mësuesi", + "Most lessons of one group on a day": "Më shumë orë mësimi të një grupi në një ditë", + "Room use": "Përdorimi i sallave" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index c57a4a2e..6c81e1fa 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Merr orarin aktual", "Could not take the current timetable.": "Orari aktual nuk u mor dot.", "Broken hard wishes": "Dëshira të detyrueshme të shkelura", - "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore" + "Not on a period of the week grid": "Nuk është në një orë të rrjetës javore", + "Compare scenarios": "Krahaso skenarët", + "Scenarios to compare": "Skenarët për krahasim", + "Choose two or three scenarios to compare them.": "Zgjidhni dy ose tre skenarë për t'i krahasuar.", + "Measure": "Masa", + "Best": "Më i miri", + "Lessons that differ": "Orë mësimi që ndryshojnë", + "No lesson differs.": "Asnjë orë mësimi nuk ndryshon.", + "Not placed": "E pavendosur", + "{period} in {room}": "{period} në {room}", + "Lessons placed": "Orë mësimi të vendosura", + "Lessons without a place": "Orë mësimi pa vend", + "Clashes": "Përplasje", + "Hard wishes broken": "Dëshira të detyrueshme të shkelura", + "Soft wishes broken": "Dëshira të buta të shkelura", + "Soft wishes broken, weighted": "Dëshira të buta të shkelura, të peshuara", + "Free periods between lessons, all teachers": "Orë bosh, të gjithë mësuesit", + "Most free periods of one teacher": "Më shumë orë bosh të një mësuesi", + "Most lessons of one group on a day": "Më shumë orë mësimi të një grupi në një ditë", + "Room use": "Përdorimi i sallave" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index 428bd739..b5606317 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Преузми тренутни распоред", "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", "Broken hard wishes": "Прекршене обавезне жеље", - "Not on a period of the week grid": "Није на часу недељне мреже" + "Not on a period of the week grid": "Није на часу недељне мреже", + "Compare scenarios": "Упореди сценарије", + "Scenarios to compare": "Сценарији за поређење", + "Choose two or three scenarios to compare them.": "Изаберите два или три сценарија за поређење.", + "Measure": "Мера", + "Best": "Најбоље", + "Lessons that differ": "Часови који се разликују", + "No lesson differs.": "Ниједан час се не разликује.", + "Not placed": "Није смештен", + "{period} in {room}": "{period} у {room}", + "Lessons placed": "Смештени часови", + "Lessons without a place": "Часови без места", + "Clashes": "Сукоби", + "Hard wishes broken": "Прекршене обавезне жеље", + "Soft wishes broken": "Прекршене флексибилне жеље", + "Soft wishes broken, weighted": "Прекршене флексибилне жеље, пондерисане", + "Free periods between lessons, all teachers": "Празни часови, сви наставници", + "Most free periods of one teacher": "Највише празних часова једног наставника", + "Most lessons of one group on a day": "Највише часова једне групе у дану", + "Room use": "Коришћење учионица" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index b891f9cd..589b656e 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Преузми тренутни распоред", "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", "Broken hard wishes": "Прекршене обавезне жеље", - "Not on a period of the week grid": "Није на часу недељне мреже" + "Not on a period of the week grid": "Није на часу недељне мреже", + "Compare scenarios": "Упореди сценарије", + "Scenarios to compare": "Сценарији за поређење", + "Choose two or three scenarios to compare them.": "Изаберите два или три сценарија за поређење.", + "Measure": "Мера", + "Best": "Најбоље", + "Lessons that differ": "Часови који се разликују", + "No lesson differs.": "Ниједан час се не разликује.", + "Not placed": "Није смештен", + "{period} in {room}": "{period} у {room}", + "Lessons placed": "Смештени часови", + "Lessons without a place": "Часови без места", + "Clashes": "Сукоби", + "Hard wishes broken": "Прекршене обавезне жеље", + "Soft wishes broken": "Прекршене флексибилне жеље", + "Soft wishes broken, weighted": "Прекршене флексибилне жеље, пондерисане", + "Free periods between lessons, all teachers": "Празни часови, сви наставници", + "Most free periods of one teacher": "Највише празних часова једног наставника", + "Most lessons of one group on a day": "Највише часова једне групе у дану", + "Room use": "Коришћење учионица" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 7d71f3bb..32700244 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Använd det nuvarande schemat", "Could not take the current timetable.": "Det gick inte att hämta det nuvarande schemat.", "Broken hard wishes": "Brutna hårda önskemål", - "Not on a period of the week grid": "Inte på en lektion i veckoschemat" + "Not on a period of the week grid": "Inte på en lektion i veckoschemat", + "Compare scenarios": "Jämför scenarier", + "Scenarios to compare": "Scenarier att jämföra", + "Choose two or three scenarios to compare them.": "Välj två eller tre scenarier att jämföra.", + "Measure": "Mått", + "Best": "Bäst", + "Lessons that differ": "Lektioner som skiljer sig", + "No lesson differs.": "Ingen lektion skiljer sig.", + "Not placed": "Inte placerad", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Placerade lektioner", + "Lessons without a place": "Lektioner utan plats", + "Clashes": "Krockar", + "Hard wishes broken": "Brutna hårda önskemål", + "Soft wishes broken": "Brutna mjuka önskemål", + "Soft wishes broken, weighted": "Brutna mjuka önskemål, viktade", + "Free periods between lessons, all teachers": "Håltimmar, alla lärare", + "Most free periods of one teacher": "Flest håltimmar för en lärare", + "Most lessons of one group on a day": "Flest lektioner för en grupp en dag", + "Room use": "Salsanvändning" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index 73ecdce0..5be81a4a 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Använd det nuvarande schemat", "Could not take the current timetable.": "Det gick inte att hämta det nuvarande schemat.", "Broken hard wishes": "Brutna hårda önskemål", - "Not on a period of the week grid": "Inte på en lektion i veckoschemat" + "Not on a period of the week grid": "Inte på en lektion i veckoschemat", + "Compare scenarios": "Jämför scenarier", + "Scenarios to compare": "Scenarier att jämföra", + "Choose two or three scenarios to compare them.": "Välj två eller tre scenarier att jämföra.", + "Measure": "Mått", + "Best": "Bäst", + "Lessons that differ": "Lektioner som skiljer sig", + "No lesson differs.": "Ingen lektion skiljer sig.", + "Not placed": "Inte placerad", + "{period} in {room}": "{period} i {room}", + "Lessons placed": "Placerade lektioner", + "Lessons without a place": "Lektioner utan plats", + "Clashes": "Krockar", + "Hard wishes broken": "Brutna hårda önskemål", + "Soft wishes broken": "Brutna mjuka önskemål", + "Soft wishes broken, weighted": "Brutna mjuka önskemål, viktade", + "Free periods between lessons, all teachers": "Håltimmar, alla lärare", + "Most free periods of one teacher": "Flest håltimmar för en lärare", + "Most lessons of one group on a day": "Flest lektioner för en grupp en dag", + "Room use": "Salsanvändning" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 3914fe95..d71f455b 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Geçerli ders programını al", "Could not take the current timetable.": "Geçerli ders programı alınamadı.", "Broken hard wishes": "Çiğnenen zorunlu istekler", - "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil" + "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil", + "Compare scenarios": "Senaryoları karşılaştır", + "Scenarios to compare": "Karşılaştırılacak senaryolar", + "Choose two or three scenarios to compare them.": "Karşılaştırmak için iki veya üç senaryo seçin.", + "Measure": "Ölçü", + "Best": "En iyi", + "Lessons that differ": "Farklı olan dersler", + "No lesson differs.": "Hiçbir ders farklı değil.", + "Not placed": "Yerleştirilmedi", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Yerleştirilen dersler", + "Lessons without a place": "Yeri olmayan dersler", + "Clashes": "Çakışmalar", + "Hard wishes broken": "Uyulmayan kesin dilekler", + "Soft wishes broken": "Uyulmayan esnek dilekler", + "Soft wishes broken, weighted": "Uyulmayan esnek dilekler, ağırlıklı", + "Free periods between lessons, all teachers": "Boş saatler, tüm öğretmenler", + "Most free periods of one teacher": "Bir öğretmenin en çok boş saati", + "Most lessons of one group on a day": "Bir grubun bir günde en çok dersi", + "Room use": "Derslik kullanımı" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index eac4fcc9..038e1e7a 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Geçerli ders programını al", "Could not take the current timetable.": "Geçerli ders programı alınamadı.", "Broken hard wishes": "Çiğnenen zorunlu istekler", - "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil" + "Not on a period of the week grid": "Haftalık tablonun bir dersinde değil", + "Compare scenarios": "Senaryoları karşılaştır", + "Scenarios to compare": "Karşılaştırılacak senaryolar", + "Choose two or three scenarios to compare them.": "Karşılaştırmak için iki veya üç senaryo seçin.", + "Measure": "Ölçü", + "Best": "En iyi", + "Lessons that differ": "Farklı olan dersler", + "No lesson differs.": "Hiçbir ders farklı değil.", + "Not placed": "Yerleştirilmedi", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Yerleştirilen dersler", + "Lessons without a place": "Yeri olmayan dersler", + "Clashes": "Çakışmalar", + "Hard wishes broken": "Uyulmayan kesin dilekler", + "Soft wishes broken": "Uyulmayan esnek dilekler", + "Soft wishes broken, weighted": "Uyulmayan esnek dilekler, ağırlıklı", + "Free periods between lessons, all teachers": "Boş saatler, tüm öğretmenler", + "Most free periods of one teacher": "Bir öğretmenin en çok boş saati", + "Most lessons of one group on a day": "Bir grubun bir günde en çok dersi", + "Room use": "Derslik kullanımı" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index 3aece6fd..fea37ccf 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1049,7 +1049,26 @@ OC.L10N.register( "Take the current timetable": "Взяти поточний розклад", "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", "Broken hard wishes": "Порушені обов'язкові побажання", - "Not on a period of the week grid": "Не на уроці тижневої сітки" + "Not on a period of the week grid": "Не на уроці тижневої сітки", + "Compare scenarios": "Порівняти сценарії", + "Scenarios to compare": "Сценарії для порівняння", + "Choose two or three scenarios to compare them.": "Виберіть два або три сценарії, щоб порівняти їх.", + "Measure": "Показник", + "Best": "Найкращий", + "Lessons that differ": "Уроки, що відрізняються", + "No lesson differs.": "Жоден урок не відрізняється.", + "Not placed": "Не розміщено", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Розміщені уроки", + "Lessons without a place": "Уроки без місця", + "Clashes": "Конфлікти", + "Hard wishes broken": "Порушені обов'язкові побажання", + "Soft wishes broken": "Порушені гнучкі побажання", + "Soft wishes broken, weighted": "Порушені гнучкі побажання, зважені", + "Free periods between lessons, all teachers": "Вікна, усі вчителі", + "Most free periods of one teacher": "Найбільше вікон в одного вчителя", + "Most lessons of one group on a day": "Найбільше уроків однієї групи за день", + "Room use": "Використання кабінетів" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 80981c76..81f032e5 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1048,7 +1048,26 @@ "Take the current timetable": "Взяти поточний розклад", "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", "Broken hard wishes": "Порушені обов'язкові побажання", - "Not on a period of the week grid": "Не на уроці тижневої сітки" + "Not on a period of the week grid": "Не на уроці тижневої сітки", + "Compare scenarios": "Порівняти сценарії", + "Scenarios to compare": "Сценарії для порівняння", + "Choose two or three scenarios to compare them.": "Виберіть два або три сценарії, щоб порівняти їх.", + "Measure": "Показник", + "Best": "Найкращий", + "Lessons that differ": "Уроки, що відрізняються", + "No lesson differs.": "Жоден урок не відрізняється.", + "Not placed": "Не розміщено", + "{period} in {room}": "{period}, {room}", + "Lessons placed": "Розміщені уроки", + "Lessons without a place": "Уроки без місця", + "Clashes": "Конфлікти", + "Hard wishes broken": "Порушені обов'язкові побажання", + "Soft wishes broken": "Порушені гнучкі побажання", + "Soft wishes broken, weighted": "Порушені гнучкі побажання, зважені", + "Free periods between lessons, all teachers": "Вікна, усі вчителі", + "Most free periods of one teacher": "Найбільше вікон в одного вчителя", + "Most lessons of one group on a day": "Найбільше уроків однієї групи за день", + "Room use": "Використання кабінетів" }, "plurals": {} } diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 64f16740..4592e6c5 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -35,7 +35,7 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 7. Compare -- [ ] 7.1 Compare page: two or three scenarios, metrics side by side with the best value marked, and the list of lessons whose period or room differs. Set `tt-scenario-compare` built. Verify: vitest on `src/utils/scenarioCompare.js` (best-value marking, lesson diff); Playwright "compare a generated and an imported scenario". +- [x] 7.1 Compare page: two or three scenarios, metrics side by side with the best value marked, and the list of lessons whose period or room differs. Set `tt-scenario-compare` built. Verify: vitest on `src/utils/scenarioCompare.js` (best-value marking, lesson diff); Playwright "compare a generated and an imported scenario". Built (section 7 PR): `src/components/TimetableScenarioCompare.vue` in the `below-header` slot of the `TimetableScenarios` index page (no new custom page, the gate-69 ratchet holds), helpers `src/utils/scenarioCompare.js`; vitest `scenarioCompare.spec.js`; Playwright case in `tests/e2e/timetable-generator.spec.ts`. ## 8. Publish diff --git a/src/components/TimetableScenarioCompare.vue b/src/components/TimetableScenarioCompare.vue new file mode 100644 index 00000000..96f536f2 --- /dev/null +++ b/src/components/TimetableScenarioCompare.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/manifest.json b/src/manifest.json index dbaef61d..ac993a4d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -255,12 +255,12 @@ ], "defaultSort": [{ "field": "reference", "order": "asc" }] }, "slots": { "form-dialog": "TimetableWishDialog" } }, - { "id": "TimetableScenarios", "_note": "The timetable scenarios, generated or taken from the current timetable, with their status and window. Admins create, edit and delete them, which OpenRegister enforces on the timetableScenario schema, so the page is a declarative index; a row opens TimetableScenarioDetail.", "route": "/timetable/scenarios", "type": "index", "title": "Timetable scenarios", "config": { + { "id": "TimetableScenarios", "_note": "The timetable scenarios, generated or taken from the current timetable, with their status and window. Admins create, edit and delete them, which OpenRegister enforces on the timetableScenario schema, so the page is a declarative index; a row opens TimetableScenarioDetail. Above the list, TimetableScenarioCompare (below-header slot) sets two or three finished scenarios side by side.", "route": "/timetable/scenarios", "type": "index", "title": "Timetable scenarios", "config": { "register": "planninq", "schema": "timetableScenario", "columns": ["title", "source", "status", "windowFrom", "windowTo"], "defaultSort": [{ "field": "windowFrom", "order": "desc" }] - } }, + }, "slots": { "below-header": "TimetableScenarioCompare" } }, { "id": "TimetableScenarioDetail", "_note": "One timetable scenario: its fields through the declarative detail page, and in its sections slot the generator run (status, progress, Generate), the lessons without a place with the hard wish that blocked them, and the broken soft wishes.", "route": "/timetable/scenarios/:id", "type": "detail", "title": "Scenario", "config": { "register": "planninq", "schema": "timetableScenario" diff --git a/src/registry.js b/src/registry.js index 400349b1..15125aba 100644 --- a/src/registry.js +++ b/src/registry.js @@ -20,6 +20,7 @@ // dashboard widget TYPE resolves against the library's own widget catalog via // registerDashboardWidget() in main.js; this registry is for page components // and slot overrides only. +import TimetableScenarioCompare from './components/TimetableScenarioCompare.vue' import TimetableScenarioSections from './components/TimetableScenarioSections.vue' import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' import Boards from './views/Boards.vue' @@ -80,6 +81,7 @@ export default { ProjectTimeline: page(ProjectTimeline), TaskDetail: page(TaskDetail), Timesheet: page(Timesheet), + TimetableScenarioCompare: slot(TimetableScenarioCompare), TimetableScenarioSections: slot(TimetableScenarioSections), TimetableWishDialog: slot(TimetableWishDialog), } diff --git a/src/utils/scenarioCompare.js b/src/utils/scenarioCompare.js new file mode 100644 index 00000000..06e98f43 --- /dev/null +++ b/src/utils/scenarioCompare.js @@ -0,0 +1,81 @@ +/** + * Helpers for comparing timetable scenarios (timetabling-generator, section 7). + * + * The metrics are the ones the scorer stored on each scenario (design decision 5), + * so a generated and an imported scenario are read the same way. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +/** + * The lines of the comparison, in order, and which way is better. `null` marks no best value. + */ +export const METRIC_LINES = [ + { key: 'placed', better: 'high' }, + { key: 'unplaced', better: 'low' }, + { key: 'clashes', better: 'low' }, + { key: 'hardWishesBroken', better: 'low' }, + { key: 'softWishesBroken', better: 'low' }, + { key: 'softPenalty', better: 'low' }, + { key: 'teacherGaps', better: 'low' }, + { key: 'teacherGapsWorst', better: 'low' }, + { key: 'lessonsPerDayWorst', better: 'low' }, + { key: 'roomUse', better: null }, +] + +/** + * The most scenarios compared at once. + */ +export const MAX_COMPARED = 3 + +/** + * One row per metric line with each scenario's value and the positions holding the best value. + * A missing value is null and never the best; when every value is equal no value is marked. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @param {object[]} scenarios Two or three scenarios. + * @return {Array<{key: string, values: Array, best: number[]}>} The rows. + */ +export function compareRows(scenarios) { + return METRIC_LINES.map((line) => { + const values = scenarios.map((scenario) => { + const value = scenario?.metrics?.[line.key] + return typeof value === 'number' ? value : null + }) + return { key: line.key, values, best: bestPositions(values, line.better) } + }) +} + +/** + * The positions of the best value, or none when there is no direction or no difference. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @param {Array} values The values. + * @param {string|null} better `low`, `high` or null. + * @return {number[]} The positions. + */ +export function bestPositions(values, better) { + const known = values.filter((value) => value !== null) + if (better === null || known.length < 2 || new Set(known).size === 1) { + return [] + } + const target = better === 'low' ? Math.min(...known) : Math.max(...known) + return values.flatMap((value, index) => (value === target ? [index] : [])) +} + +/** + * The lessons whose period or room differs between the scenarios, in lesson order; a lesson a + * scenario did not place shows null in that scenario's cell. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @param {object[]} scenarios Two or three scenarios. + * @return {Array<{lesson: string, cells: Array<{period: string, room: string}|null>}>} The differing lessons. + */ +export function lessonDiff(scenarios) { + const maps = scenarios.map((scenario) => new Map((scenario?.placements ?? []).map((row) => [row.lesson, { period: row.period, room: row.room }]))) + const lessons = [...new Set(maps.flatMap((map) => [...map.keys()]))].sort() + return lessons + .map((lesson) => ({ lesson, cells: maps.map((map) => map.get(lesson) ?? null) })) + .filter((row) => new Set(row.cells.map((cell) => (cell === null ? '' : `${cell.period} ${cell.room}`))).size > 1) +} diff --git a/tests/e2e/timetable-generator.spec.ts b/tests/e2e/timetable-generator.spec.ts index 0db8186c..0400c49f 100644 --- a/tests/e2e/timetable-generator.spec.ts +++ b/tests/e2e/timetable-generator.spec.ts @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * E2E coverage for a generator run (timetabling-generator, section 5). + * E2E coverage for a generator run (timetabling-generator, section 5) and the + * scenario comparison (section 7). * * @e2e timetable-generator::generate-a-scenario-that-respects-a-hard-wish + * @e2e timetable-generator::compare-a-generated-and-an-imported-scenario * * The run itself is a queued job, so the test starts it through the endpoint, * runs the background jobs with `occ background-job:worker` when the harness @@ -89,4 +91,29 @@ test.describe('Timetable generator', () => { await expect(page.getByTestId('scenario-sections')).toBeVisible() await expect(page.getByTestId('scenario-status')).not.toBeEmpty() }) + test('compare a generated and an imported scenario', async ({ page }) => { + const base = { weekOf: '2026-10-05', windowFrom: '2026-10-05', windowTo: '2026-10-30', status: 'done' } + const scenarios = [ + { ...base, title: `Generated ${RUN}`, source: 'generated', metrics: { placed: 3, unplaced: 0, clashes: 0, hardWishesBroken: 0, softWishesBroken: 0, softPenalty: 0, teacherGaps: 1, teacherGapsWorst: 1, lessonsPerDayWorst: 2, roomUse: 0.1 }, placements: [{ lesson: '3A:English:1', period: 'mon-1', room: 'B12' }, { lesson: '3A:English:2', period: 'tue-1', room: 'B12' }] }, + { ...base, title: `Imported ${RUN}`, source: 'imported', metrics: { placed: 3, unplaced: 0, clashes: 0, hardWishesBroken: 1, softWishesBroken: 0, softPenalty: 0, teacherGaps: 3, teacherGapsWorst: 2, lessonsPerDayWorst: 2, roomUse: 0.1 }, placements: [{ lesson: '3A:English:1', period: 'mon-1', room: 'B12' }, { lesson: '3A:English:2', period: 'wed-6', room: 'B12' }] }, + ] + for (const data of scenarios) { + const response = await api.post(`${OBJECTS}/timetableScenario`, { data }) + const body = await response.json() + created.push(`${OBJECTS}/timetableScenario/${body.id ?? body['@self']?.id}`) + } + + await page.goto(`${PLANNINQ_ROOT}timetable/scenarios`) + const select = page.getByTestId('scenario-compare-select') + for (const data of scenarios) { + await select.click() + await page.keyboard.type(data.title) + await page.keyboard.press('Enter') + } + + await expect(page.getByTestId('scenario-compare-metrics')).toBeVisible() + await expect(page.getByTestId('compare-hardWishesBroken-0')).toHaveClass(/scenario-compare__best/) + await expect(page.getByTestId('compare-hardWishesBroken-1')).not.toHaveClass(/scenario-compare__best/) + await expect(page.getByTestId('scenario-compare-diff')).toContainText('3A:English:2') + }) }) diff --git a/tests/vitest/scenarioCompare.spec.js b/tests/vitest/scenarioCompare.spec.js new file mode 100644 index 00000000..18330d0e --- /dev/null +++ b/tests/vitest/scenarioCompare.spec.js @@ -0,0 +1,70 @@ +/** + * Vitest tests for comparing timetable scenarios (timetabling-generator, section 7). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + */ +import { describe, expect, it } from 'vitest' +import { bestPositions, compareRows, lessonDiff } from '../../src/utils/scenarioCompare.js' + +const generated = { + source: 'generated', + metrics: { placed: 5, unplaced: 0, clashes: 0, hardWishesBroken: 0, softWishesBroken: 1, softPenalty: 2, teacherGaps: 1, teacherGapsWorst: 1, lessonsPerDayWorst: 3, roomUse: 0.3 }, + placements: [ + { lesson: '3A:English:1', period: 'mon-1', room: 'r-1' }, + { lesson: '3A:English:2', period: 'mon-2', room: 'r-1' }, + { lesson: '3B:Maths:1', period: 'wed-1', room: 'r-2' }, + ], +} +const imported = { + source: 'imported', + metrics: { placed: 5, unplaced: 0, clashes: 0, hardWishesBroken: 1, softWishesBroken: 1, softPenalty: 3, teacherGaps: 4, teacherGapsWorst: 2, lessonsPerDayWorst: 3, roomUse: 0.3 }, + placements: [ + { lesson: '3A:English:1', period: 'mon-1', room: 'r-1' }, + { lesson: '3A:English:2', period: 'wed-3', room: 'r-1' }, + { lesson: '3B:Maths:1', period: 'wed-1', room: 'r-1' }, + { lesson: '3B:Art:1', period: 'mon-4', room: 's-1' }, + ], +} + +describe('compareRows (scenario: compare a generated and an imported scenario)', () => { + it('marks the best value per line', () => { + const rows = Object.fromEntries(compareRows([generated, imported]).map((row) => [row.key, row])) + expect(rows.hardWishesBroken).toEqual({ key: 'hardWishesBroken', values: [0, 1], best: [0] }) + expect(rows.teacherGaps.best).toEqual([0]) + expect(rows.softPenalty.best).toEqual([0]) + }) + + it('marks nothing when the values are equal or the line has no direction', () => { + const rows = Object.fromEntries(compareRows([generated, imported]).map((row) => [row.key, row])) + expect(rows.placed.best).toEqual([]) + expect(rows.roomUse.best).toEqual([]) + }) + + it('keeps a missing value as null and never marks it', () => { + const rows = Object.fromEntries(compareRows([generated, { metrics: {} }, imported]).map((row) => [row.key, row])) + expect(rows.teacherGaps.values).toEqual([1, null, 4]) + expect(rows.teacherGaps.best).toEqual([0]) + }) +}) + +describe('bestPositions', () => { + it('marks every position that shares the best value', () => { + expect(bestPositions([2, 1, 1], 'low')).toEqual([1, 2]) + expect(bestPositions([2, 5, 5], 'high')).toEqual([1, 2]) + expect(bestPositions([2, null], 'low')).toEqual([]) + }) +}) + +describe('lessonDiff', () => { + it('lists the lessons whose period or room differs, a missing lesson as null', () => { + expect(lessonDiff([generated, imported])).toEqual([ + { lesson: '3A:English:2', cells: [{ period: 'mon-2', room: 'r-1' }, { period: 'wed-3', room: 'r-1' }] }, + { lesson: '3B:Art:1', cells: [null, { period: 'mon-4', room: 's-1' }] }, + { lesson: '3B:Maths:1', cells: [{ period: 'wed-1', room: 'r-2' }, { period: 'wed-1', room: 'r-1' }] }, + ]) + }) + + it('is empty for two equal scenarios', () => { + expect(lessonDiff([generated, generated])).toEqual([]) + }) +}) From 3d871edf1f9671339b76a4850f6113ae12247065 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 07:53:43 +0200 Subject: [PATCH 10/14] chore(parity): tt-scenario-compare built --- openspec/parity/capabilities.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index f9fc2c54..4fec5696 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8818,10 +8818,11 @@ "timeedit": "docs read 2026-09-27: https://timeedit.com/platform/scheduling/scenario-planning 'Create unlimited what-if scenarios ... Compare scenarios across key metrics like room utilization, student satisfaction, and staff workload distribution'" }, "built": { - "state": "specified", - "evidence": "Nothing in planninq or learniq generates a timetable: planninq stores lessons delivered by a rostering system (lib/Service/TimetableSessionService.php upsert, drafts and publish) and places none. Ruben decided on 29 Sep 2026 to build a generator in planninq (build-all DECISIONS.md row 17), reversing the 28 Sep decided-no and the learniq non-goal; learniq decided no in ConductionNL/learniq#1493 before that decision.", + "state": "built", + "evidence": "Compare: src/components/TimetableScenarioCompare.vue on the TimetableScenarios index page (below-header slot) sets two or three finished scenarios, generated or taken from the current timetable (TimetableScenarioImporter, POST /api/timetable/scenarios/{id}/import), side by side on the metrics TimetableScorer stored on each (lessons placed and unplaced, clashes, hard and soft wishes broken, weighted soft penalty, teacher gaps, lessons per day, room use), marks the best value per line and lists the lessons whose period or room differs (src/utils/scenarioCompare.js). Tests: vitest scenarioCompare.spec.js, PHPUnit TimetableScenarioImporterTest (an imported timetable that breaks a hard wish shows it in metrics), Playwright timetable-generator.spec.ts compare case.", "owner": "ConductionNL/planninq", - "change": "openspec/changes/timetabling-generator" + "change": "openspec/changes/timetabling-generator", + "readOn": "2026-09-30" }, "provider": "learniq", "providerHow": "read-from-code", From e10b787dadede0ece95566fefb01f51dcb6b5ed9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 08:55:29 +0200 Subject: [PATCH 11/14] feat(timetable): publish a generated scenario as draft lessons for every week of its window --- appinfo/routes.php | 2 + l10n/be.js | 5 +- l10n/be.json | 5 +- l10n/bg.js | 5 +- l10n/bg.json | 5 +- l10n/bs.js | 5 +- l10n/bs.json | 5 +- l10n/ca.js | 5 +- l10n/ca.json | 5 +- l10n/cs.js | 5 +- l10n/cs.json | 5 +- l10n/da.js | 5 +- l10n/da.json | 5 +- l10n/de.js | 5 +- l10n/de.json | 5 +- l10n/el.js | 5 +- l10n/el.json | 5 +- l10n/en.js | 5 +- l10n/en.json | 5 +- l10n/es.js | 5 +- l10n/es.json | 5 +- l10n/et.js | 5 +- l10n/et.json | 5 +- l10n/fi.js | 5 +- l10n/fi.json | 5 +- l10n/fr.js | 5 +- l10n/fr.json | 5 +- l10n/ga.js | 5 +- l10n/ga.json | 5 +- l10n/hr.js | 5 +- l10n/hr.json | 5 +- l10n/hu.js | 5 +- l10n/hu.json | 5 +- l10n/is.js | 5 +- l10n/is.json | 5 +- l10n/it.js | 5 +- l10n/it.json | 5 +- l10n/lb.js | 5 +- l10n/lb.json | 5 +- l10n/lt.js | 5 +- l10n/lt.json | 5 +- l10n/lv.js | 5 +- l10n/lv.json | 5 +- l10n/mk.js | 5 +- l10n/mk.json | 5 +- l10n/mt.js | 5 +- l10n/mt.json | 5 +- l10n/nb.js | 5 +- l10n/nb.json | 5 +- l10n/nl.js | 5 +- l10n/nl.json | 5 +- l10n/pl.js | 5 +- l10n/pl.json | 5 +- l10n/pt.js | 5 +- l10n/pt.json | 5 +- l10n/rm.js | 5 +- l10n/rm.json | 5 +- l10n/ro.js | 5 +- l10n/ro.json | 5 +- l10n/ru.js | 5 +- l10n/ru.json | 5 +- l10n/sk.js | 5 +- l10n/sk.json | 5 +- l10n/sl.js | 5 +- l10n/sl.json | 5 +- l10n/sq.js | 5 +- l10n/sq.json | 5 +- l10n/sr.js | 5 +- l10n/sr.json | 5 +- l10n/sv.js | 5 +- l10n/sv.json | 5 +- l10n/tr.js | 5 +- l10n/tr.json | 5 +- l10n/uk.js | 5 +- l10n/uk.json | 5 +- .../TimetableScenarioController.php | 29 +- lib/Service/TimetableScenarioPublisher.php | 221 +++++++++++++ lib/Service/TimetableScenarioStore.php | 43 +++ .../changes/timetabling-generator/tasks.md | 2 +- src/components/TimetableScenarioSections.vue | 44 ++- src/utils/timetableScenarios.js | 12 + .../TimetableScenarioControllerTest.php | 51 ++- .../TimetableScenarioPublisherTest.php | 297 ++++++++++++++++++ tests/vitest/timetableScenarios.spec.js | 14 +- 84 files changed, 1004 insertions(+), 81 deletions(-) create mode 100644 lib/Service/TimetableScenarioPublisher.php create mode 100644 tests/unit/Service/TimetableScenarioPublisherTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 3f61b44a..8dd2f5d8 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -59,4 +59,6 @@ ['name' => 'timetableScenario#generate', 'url' => '/api/timetable/scenarios/{id}/generate', 'verb' => 'POST'], // Admins take the current timetable into an imported scenario (timetabling-generator 6.1). ['name' => 'timetableScenario#importCurrent', 'url' => '/api/timetable/scenarios/{id}/import', 'verb' => 'POST'], + // Admins publish a scenario as draft lessons for its window (timetabling-generator 8.1). + ['name' => 'timetableScenario#publishDrafts', 'url' => '/api/timetable/scenarios/{id}/publish-drafts', 'verb' => 'POST'], ]); diff --git a/l10n/be.js b/l10n/be.js index fa681aa9..f83df88d 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Вокны, усе настаўнікі", "Most free periods of one teacher": "Найбольш вокнаў у аднаго настаўніка", "Most lessons of one group on a day": "Найбольш урокаў адной групы за дзень", - "Room use": "Выкарыстанне кабінетаў" + "Room use": "Выкарыстанне кабінетаў", + "Publish as draft lessons": "Апублікаваць як чарнавікі ўрокаў", + "Draft lessons written: {count}": "Запісана чарнавікоў урокаў: {count}", + "Could not publish the scenario.": "Не ўдалося апублікаваць сцэнарый." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index d0ae7db8..62580384 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Вокны, усе настаўнікі", "Most free periods of one teacher": "Найбольш вокнаў у аднаго настаўніка", "Most lessons of one group on a day": "Найбольш урокаў адной групы за дзень", - "Room use": "Выкарыстанне кабінетаў" + "Room use": "Выкарыстанне кабінетаў", + "Publish as draft lessons": "Апублікаваць як чарнавікі ўрокаў", + "Draft lessons written: {count}": "Запісана чарнавікоў урокаў: {count}", + "Could not publish the scenario.": "Не ўдалося апублікаваць сцэнарый." }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 9f28e486..54b4cf3f 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Свободни часове, всички учители", "Most free periods of one teacher": "Най-много свободни часове на един учител", "Most lessons of one group on a day": "Най-много часове на една група за ден", - "Room use": "Използване на стаите" + "Room use": "Използване на стаите", + "Publish as draft lessons": "Публикувай като чернови на часове", + "Draft lessons written: {count}": "Записани чернови на часове: {count}", + "Could not publish the scenario.": "Сценарият не можа да бъде публикуван." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index 7cf9b591..fba69b39 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Свободни часове, всички учители", "Most free periods of one teacher": "Най-много свободни часове на един учител", "Most lessons of one group on a day": "Най-много часове на една група за ден", - "Room use": "Използване на стаите" + "Room use": "Използване на стаите", + "Publish as draft lessons": "Публикувай като чернови на часове", + "Draft lessons written: {count}": "Записани чернови на часове: {count}", + "Could not publish the scenario.": "Сценарият не можа да бъде публикуван." }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index 73660e13..906e9f2b 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Prazni časovi, svi nastavnici", "Most free periods of one teacher": "Najviše praznih časova jednog nastavnika", "Most lessons of one group on a day": "Najviše časova jedne grupe u danu", - "Room use": "Korištenje učionica" + "Room use": "Korištenje učionica", + "Publish as draft lessons": "Objavi kao nacrte časova", + "Draft lessons written: {count}": "Zapisani nacrti časova: {count}", + "Could not publish the scenario.": "Scenarij nije moguće objaviti." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 3ee35c20..ef58fddd 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Prazni časovi, svi nastavnici", "Most free periods of one teacher": "Najviše praznih časova jednog nastavnika", "Most lessons of one group on a day": "Najviše časova jedne grupe u danu", - "Room use": "Korištenje učionica" + "Room use": "Korištenje učionica", + "Publish as draft lessons": "Objavi kao nacrte časova", + "Draft lessons written: {count}": "Zapisani nacrti časova: {count}", + "Could not publish the scenario.": "Scenarij nije moguće objaviti." }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index 85c3cfe5..71db75c0 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Hores lliures, tots els docents", "Most free periods of one teacher": "Més hores lliures d'un docent", "Most lessons of one group on a day": "Més classes d'un grup en un dia", - "Room use": "Ús de les aules" + "Room use": "Ús de les aules", + "Publish as draft lessons": "Publica com a classes esborrany", + "Draft lessons written: {count}": "Classes esborrany escrites: {count}", + "Could not publish the scenario.": "No s'ha pogut publicar l'escenari." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 0180ea79..787bcb47 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Hores lliures, tots els docents", "Most free periods of one teacher": "Més hores lliures d'un docent", "Most lessons of one group on a day": "Més classes d'un grup en un dia", - "Room use": "Ús de les aules" + "Room use": "Ús de les aules", + "Publish as draft lessons": "Publica com a classes esborrany", + "Draft lessons written: {count}": "Classes esborrany escrites: {count}", + "Could not publish the scenario.": "No s'ha pogut publicar l'escenari." }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 5a777b93..a813bbf6 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Volné hodiny, všichni učitelé", "Most free periods of one teacher": "Nejvíc volných hodin jednoho učitele", "Most lessons of one group on a day": "Nejvíc hodin jedné skupiny za den", - "Room use": "Využití učeben" + "Room use": "Využití učeben", + "Publish as draft lessons": "Zveřejnit jako koncepty hodin", + "Draft lessons written: {count}": "Zapsané koncepty hodin: {count}", + "Could not publish the scenario.": "Scénář se nepodařilo zveřejnit." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 1cc94da3..bd9f6a19 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Volné hodiny, všichni učitelé", "Most free periods of one teacher": "Nejvíc volných hodin jednoho učitele", "Most lessons of one group on a day": "Nejvíc hodin jedné skupiny za den", - "Room use": "Využití učeben" + "Room use": "Využití učeben", + "Publish as draft lessons": "Zveřejnit jako koncepty hodin", + "Draft lessons written: {count}": "Zapsané koncepty hodin: {count}", + "Could not publish the scenario.": "Scénář se nepodařilo zveřejnit." }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 37982ac6..22f219b4 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Mellemtimer, alle lærere", "Most free periods of one teacher": "Flest mellemtimer for én lærer", "Most lessons of one group on a day": "Flest lektioner for én gruppe på en dag", - "Room use": "Lokalebrug" + "Room use": "Lokalebrug", + "Publish as draft lessons": "Udgiv som kladdelektioner", + "Draft lessons written: {count}": "Kladdelektioner skrevet: {count}", + "Could not publish the scenario.": "Scenariet kunne ikke udgives." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 0401080c..410e4c93 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Mellemtimer, alle lærere", "Most free periods of one teacher": "Flest mellemtimer for én lærer", "Most lessons of one group on a day": "Flest lektioner for én gruppe på en dag", - "Room use": "Lokalebrug" + "Room use": "Lokalebrug", + "Publish as draft lessons": "Udgiv som kladdelektioner", + "Draft lessons written: {count}": "Kladdelektioner skrevet: {count}", + "Could not publish the scenario.": "Scenariet kunne ikke udgives." }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index d3c85ed9..251fbd48 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Freistunden, alle Lehrkräfte", "Most free periods of one teacher": "Meiste Freistunden einer Lehrkraft", "Most lessons of one group on a day": "Meiste Stunden einer Gruppe an einem Tag", - "Room use": "Raumnutzung" + "Room use": "Raumnutzung", + "Publish as draft lessons": "Als Entwurfsstunden veröffentlichen", + "Draft lessons written: {count}": "Entwurfsstunden geschrieben: {count}", + "Could not publish the scenario.": "Das Szenario konnte nicht veröffentlicht werden." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 8e3f37e0..1bcb1aa5 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Freistunden, alle Lehrkräfte", "Most free periods of one teacher": "Meiste Freistunden einer Lehrkraft", "Most lessons of one group on a day": "Meiste Stunden einer Gruppe an einem Tag", - "Room use": "Raumnutzung" + "Room use": "Raumnutzung", + "Publish as draft lessons": "Als Entwurfsstunden veröffentlichen", + "Draft lessons written: {count}": "Entwurfsstunden geschrieben: {count}", + "Could not publish the scenario.": "Das Szenario konnte nicht veröffentlicht werden." }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index 1a93ce73..81baf812 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Κενά, όλοι οι εκπαιδευτικοί", "Most free periods of one teacher": "Τα περισσότερα κενά ενός εκπαιδευτικού", "Most lessons of one group on a day": "Τα περισσότερα μαθήματα μιας ομάδας σε μια ημέρα", - "Room use": "Χρήση αιθουσών" + "Room use": "Χρήση αιθουσών", + "Publish as draft lessons": "Δημοσίευση ως πρόχειρα μαθήματα", + "Draft lessons written: {count}": "Πρόχειρα μαθήματα που γράφτηκαν: {count}", + "Could not publish the scenario.": "Δεν ήταν δυνατή η δημοσίευση του σεναρίου." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index fa28e650..b11a906e 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Κενά, όλοι οι εκπαιδευτικοί", "Most free periods of one teacher": "Τα περισσότερα κενά ενός εκπαιδευτικού", "Most lessons of one group on a day": "Τα περισσότερα μαθήματα μιας ομάδας σε μια ημέρα", - "Room use": "Χρήση αιθουσών" + "Room use": "Χρήση αιθουσών", + "Publish as draft lessons": "Δημοσίευση ως πρόχειρα μαθήματα", + "Draft lessons written: {count}": "Πρόχειρα μαθήματα που γράφτηκαν: {count}", + "Could not publish the scenario.": "Δεν ήταν δυνατή η δημοσίευση του σεναρίου." }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 15ea226c..4f26e9e8 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1046,7 +1046,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Free periods between lessons, all teachers", "Most free periods of one teacher": "Most free periods of one teacher", "Most lessons of one group on a day": "Most lessons of one group on a day", - "Room use": "Room use" + "Room use": "Room use", + "Publish as draft lessons": "Publish as draft lessons", + "Draft lessons written: {count}": "Draft lessons written: {count}", + "Could not publish the scenario.": "Could not publish the scenario." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 45bedc90..1fce4a76 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1045,7 +1045,10 @@ "Free periods between lessons, all teachers": "Free periods between lessons, all teachers", "Most free periods of one teacher": "Most free periods of one teacher", "Most lessons of one group on a day": "Most lessons of one group on a day", - "Room use": "Room use" + "Room use": "Room use", + "Publish as draft lessons": "Publish as draft lessons", + "Draft lessons written: {count}": "Draft lessons written: {count}", + "Could not publish the scenario.": "Could not publish the scenario." }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index 724e58db..09c93951 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Horas libres entre clases, todos los docentes", "Most free periods of one teacher": "Más horas libres de un docente", "Most lessons of one group on a day": "Más clases de un grupo en un día", - "Room use": "Uso de aulas" + "Room use": "Uso de aulas", + "Publish as draft lessons": "Publicar como clases en borrador", + "Draft lessons written: {count}": "Clases en borrador escritas: {count}", + "Could not publish the scenario.": "No se pudo publicar el escenario." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index fb3e0448..edca48c4 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Horas libres entre clases, todos los docentes", "Most free periods of one teacher": "Más horas libres de un docente", "Most lessons of one group on a day": "Más clases de un grupo en un día", - "Room use": "Uso de aulas" + "Room use": "Uso de aulas", + "Publish as draft lessons": "Publicar como clases en borrador", + "Draft lessons written: {count}": "Clases en borrador escritas: {count}", + "Could not publish the scenario.": "No se pudo publicar el escenario." }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index 25de6092..cba15b49 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Aknatunnid, kõik õpetajad", "Most free periods of one teacher": "Ühe õpetaja kõige rohkem aknatunde", "Most lessons of one group on a day": "Ühe rühma kõige rohkem tunde päevas", - "Room use": "Ruumide kasutus" + "Room use": "Ruumide kasutus", + "Publish as draft lessons": "Avalda mustandtundidena", + "Draft lessons written: {count}": "Mustandtunde kirjutatud: {count}", + "Could not publish the scenario.": "Stsenaariumi ei õnnestunud avaldada." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 84494ba7..7ff64baf 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Aknatunnid, kõik õpetajad", "Most free periods of one teacher": "Ühe õpetaja kõige rohkem aknatunde", "Most lessons of one group on a day": "Ühe rühma kõige rohkem tunde päevas", - "Room use": "Ruumide kasutus" + "Room use": "Ruumide kasutus", + "Publish as draft lessons": "Avalda mustandtundidena", + "Draft lessons written: {count}": "Mustandtunde kirjutatud: {count}", + "Could not publish the scenario.": "Stsenaariumi ei õnnestunud avaldada." }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 4a29ada9..0b6fc337 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Hyppytunnit, kaikki opettajat", "Most free periods of one teacher": "Yhden opettajan eniten hyppytunteja", "Most lessons of one group on a day": "Yhden ryhmän eniten tunteja päivässä", - "Room use": "Tilojen käyttö" + "Room use": "Tilojen käyttö", + "Publish as draft lessons": "Julkaise luonnostunteina", + "Draft lessons written: {count}": "Luonnostunteja kirjoitettu: {count}", + "Could not publish the scenario.": "Skenaariota ei voitu julkaista." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 158bd307..f2f9168a 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Hyppytunnit, kaikki opettajat", "Most free periods of one teacher": "Yhden opettajan eniten hyppytunteja", "Most lessons of one group on a day": "Yhden ryhmän eniten tunteja päivässä", - "Room use": "Tilojen käyttö" + "Room use": "Tilojen käyttö", + "Publish as draft lessons": "Julkaise luonnostunteina", + "Draft lessons written: {count}": "Luonnostunteja kirjoitettu: {count}", + "Could not publish the scenario.": "Skenaariota ei voitu julkaista." }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 363d1eca..4854d484 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Heures creuses, tous les enseignants", "Most free periods of one teacher": "Plus d'heures creuses pour un enseignant", "Most lessons of one group on a day": "Plus de cours d'un groupe en un jour", - "Room use": "Occupation des salles" + "Room use": "Occupation des salles", + "Publish as draft lessons": "Publier comme cours brouillons", + "Draft lessons written: {count}": "Cours brouillons écrits : {count}", + "Could not publish the scenario.": "Impossible de publier le scénario." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 24953b3a..ac803e40 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Heures creuses, tous les enseignants", "Most free periods of one teacher": "Plus d'heures creuses pour un enseignant", "Most lessons of one group on a day": "Plus de cours d'un groupe en un jour", - "Room use": "Occupation des salles" + "Room use": "Occupation des salles", + "Publish as draft lessons": "Publier comme cours brouillons", + "Draft lessons written: {count}": "Cours brouillons écrits : {count}", + "Could not publish the scenario.": "Impossible de publier le scénario." }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index d27dd8b7..36ca43d1 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Tréimhsí saora, gach múinteoir", "Most free periods of one teacher": "Na tréimhsí saora is mó ag múinteoir amháin", "Most lessons of one group on a day": "Na ceachtanna is mó ag grúpa amháin in aon lá", - "Room use": "Úsáid seomraí" + "Room use": "Úsáid seomraí", + "Publish as draft lessons": "Foilsigh mar dhréachtcheachtanna", + "Draft lessons written: {count}": "Dréachtcheachtanna scríofa: {count}", + "Could not publish the scenario.": "Níorbh fhéidir an cás a fhoilsiú." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 63dceac8..f4efde2b 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Tréimhsí saora, gach múinteoir", "Most free periods of one teacher": "Na tréimhsí saora is mó ag múinteoir amháin", "Most lessons of one group on a day": "Na ceachtanna is mó ag grúpa amháin in aon lá", - "Room use": "Úsáid seomraí" + "Room use": "Úsáid seomraí", + "Publish as draft lessons": "Foilsigh mar dhréachtcheachtanna", + "Draft lessons written: {count}": "Dréachtcheachtanna scríofa: {count}", + "Could not publish the scenario.": "Níorbh fhéidir an cás a fhoilsiú." }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 5e76baab..7983646c 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Prazni sati, svi nastavnici", "Most free periods of one teacher": "Najviše praznih sati jednog nastavnika", "Most lessons of one group on a day": "Najviše sati jedne grupe u danu", - "Room use": "Korištenje učionica" + "Room use": "Korištenje učionica", + "Publish as draft lessons": "Objavi kao nacrte sati", + "Draft lessons written: {count}": "Zapisani nacrti sati: {count}", + "Could not publish the scenario.": "Scenarij nije moguće objaviti." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index c6e09467..ed70a158 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Prazni sati, svi nastavnici", "Most free periods of one teacher": "Najviše praznih sati jednog nastavnika", "Most lessons of one group on a day": "Najviše sati jedne grupe u danu", - "Room use": "Korištenje učionica" + "Room use": "Korištenje učionica", + "Publish as draft lessons": "Objavi kao nacrte sati", + "Draft lessons written: {count}": "Zapisani nacrti sati: {count}", + "Could not publish the scenario.": "Scenarij nije moguće objaviti." }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index ddf057d2..155f9a45 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Lyukasórák, minden tanár", "Most free periods of one teacher": "Egy tanár legtöbb lyukasórája", "Most lessons of one group on a day": "Egy csoport legtöbb órája egy napon", - "Room use": "Teremkihasználtság" + "Room use": "Teremkihasználtság", + "Publish as draft lessons": "Közzététel óratervezetként", + "Draft lessons written: {count}": "Megírt óratervezetek: {count}", + "Could not publish the scenario.": "Nem sikerült közzétenni a forgatókönyvet." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 49bc9e2a..fac65fb9 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Lyukasórák, minden tanár", "Most free periods of one teacher": "Egy tanár legtöbb lyukasórája", "Most lessons of one group on a day": "Egy csoport legtöbb órája egy napon", - "Room use": "Teremkihasználtság" + "Room use": "Teremkihasználtság", + "Publish as draft lessons": "Közzététel óratervezetként", + "Draft lessons written: {count}": "Megírt óratervezetek: {count}", + "Could not publish the scenario.": "Nem sikerült közzétenni a forgatókönyvet." }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 16945e2e..1e284731 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Eyður, allir kennarar", "Most free periods of one teacher": "Flestar eyður hjá einum kennara", "Most lessons of one group on a day": "Flestar kennslustundir hjá einum hópi á dag", - "Room use": "Nýting stofa" + "Room use": "Nýting stofa", + "Publish as draft lessons": "Birta sem drög að kennslustundum", + "Draft lessons written: {count}": "Drög að kennslustundum skrifuð: {count}", + "Could not publish the scenario.": "Ekki tókst að birta sviðsmyndina." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index aff481e9..b3919d25 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Eyður, allir kennarar", "Most free periods of one teacher": "Flestar eyður hjá einum kennara", "Most lessons of one group on a day": "Flestar kennslustundir hjá einum hópi á dag", - "Room use": "Nýting stofa" + "Room use": "Nýting stofa", + "Publish as draft lessons": "Birta sem drög að kennslustundum", + "Draft lessons written: {count}": "Drög að kennslustundum skrifuð: {count}", + "Could not publish the scenario.": "Ekki tókst að birta sviðsmyndina." }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index db06f91a..ee268ecf 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Ore buche, tutti i docenti", "Most free periods of one teacher": "Più ore buche di un docente", "Most lessons of one group on a day": "Più lezioni di un gruppo in un giorno", - "Room use": "Uso delle aule" + "Room use": "Uso delle aule", + "Publish as draft lessons": "Pubblica come lezioni in bozza", + "Draft lessons written: {count}": "Lezioni in bozza scritte: {count}", + "Could not publish the scenario.": "Impossibile pubblicare lo scenario." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 5da6b277..dfcb7b18 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Ore buche, tutti i docenti", "Most free periods of one teacher": "Più ore buche di un docente", "Most lessons of one group on a day": "Più lezioni di un gruppo in un giorno", - "Room use": "Uso delle aule" + "Room use": "Uso delle aule", + "Publish as draft lessons": "Pubblica come lezioni in bozza", + "Draft lessons written: {count}": "Lezioni in bozza scritte: {count}", + "Could not publish the scenario.": "Impossibile pubblicare lo scenario." }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 01a195ff..d6253e2d 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Fräistonnen, all Enseignanten", "Most free periods of one teacher": "Meeschte Fräistonne vun engem Enseignant", "Most lessons of one group on a day": "Meeschte Stonne vun enger Grupp un engem Dag", - "Room use": "Sallbenotzung" + "Room use": "Sallbenotzung", + "Publish as draft lessons": "Als Entworfsstonne publizéieren", + "Draft lessons written: {count}": "Entworfsstonne geschriwwen: {count}", + "Could not publish the scenario.": "De Szenario konnt net publizéiert ginn." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 50221dd2..c7d4eab1 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Fräistonnen, all Enseignanten", "Most free periods of one teacher": "Meeschte Fräistonne vun engem Enseignant", "Most lessons of one group on a day": "Meeschte Stonne vun enger Grupp un engem Dag", - "Room use": "Sallbenotzung" + "Room use": "Sallbenotzung", + "Publish as draft lessons": "Als Entworfsstonne publizéieren", + "Draft lessons written: {count}": "Entworfsstonne geschriwwen: {count}", + "Could not publish the scenario.": "De Szenario konnt net publizéiert ginn." }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 0906223c..e4011bcc 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Langai, visi mokytojai", "Most free periods of one teacher": "Daugiausia vieno mokytojo langų", "Most lessons of one group on a day": "Daugiausia vienos grupės pamokų per dieną", - "Room use": "Patalpų naudojimas" + "Room use": "Patalpų naudojimas", + "Publish as draft lessons": "Paskelbti kaip juodraštines pamokas", + "Draft lessons written: {count}": "Įrašyta juodraštinių pamokų: {count}", + "Could not publish the scenario.": "Nepavyko paskelbti scenarijaus." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index eb822b2c..f888caf5 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Langai, visi mokytojai", "Most free periods of one teacher": "Daugiausia vieno mokytojo langų", "Most lessons of one group on a day": "Daugiausia vienos grupės pamokų per dieną", - "Room use": "Patalpų naudojimas" + "Room use": "Patalpų naudojimas", + "Publish as draft lessons": "Paskelbti kaip juodraštines pamokas", + "Draft lessons written: {count}": "Įrašyta juodraštinių pamokų: {count}", + "Could not publish the scenario.": "Nepavyko paskelbti scenarijaus." }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 2ab47307..a0c64785 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Brīvstundas, visi skolotāji", "Most free periods of one teacher": "Viena skolotāja visvairāk brīvstundu", "Most lessons of one group on a day": "Vienas grupas visvairāk stundu dienā", - "Room use": "Telpu izmantojums" + "Room use": "Telpu izmantojums", + "Publish as draft lessons": "Publicēt kā melnraksta stundas", + "Draft lessons written: {count}": "Uzrakstītas melnraksta stundas: {count}", + "Could not publish the scenario.": "Neizdevās publicēt scenāriju." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 69441905..ef6e04d1 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Brīvstundas, visi skolotāji", "Most free periods of one teacher": "Viena skolotāja visvairāk brīvstundu", "Most lessons of one group on a day": "Vienas grupas visvairāk stundu dienā", - "Room use": "Telpu izmantojums" + "Room use": "Telpu izmantojums", + "Publish as draft lessons": "Publicēt kā melnraksta stundas", + "Draft lessons written: {count}": "Uzrakstītas melnraksta stundas: {count}", + "Could not publish the scenario.": "Neizdevās publicēt scenāriju." }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 925ca846..f08d5100 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Слободни часови, сите наставници", "Most free periods of one teacher": "Најмногу слободни часови на еден наставник", "Most lessons of one group on a day": "Најмногу часови на една група во ден", - "Room use": "Користење на простории" + "Room use": "Користење на простории", + "Publish as draft lessons": "Објави како нацрт-часови", + "Draft lessons written: {count}": "Запишани нацрт-часови: {count}", + "Could not publish the scenario.": "Сценариото не можеше да се објави." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index eeb7d16b..419fab88 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Слободни часови, сите наставници", "Most free periods of one teacher": "Најмногу слободни часови на еден наставник", "Most lessons of one group on a day": "Најмногу часови на една група во ден", - "Room use": "Користење на простории" + "Room use": "Користење на простории", + "Publish as draft lessons": "Објави како нацрт-часови", + "Draft lessons written: {count}": "Запишани нацрт-часови: {count}", + "Could not publish the scenario.": "Сценариото не можеше да се објави." }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index 3ebaffaf..c059c31f 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Perjodi ħielsa, l-għalliema kollha", "Most free periods of one teacher": "L-aktar perjodi ħielsa ta' għalliem wieħed", "Most lessons of one group on a day": "L-aktar lezzjonijiet ta' grupp wieħed f'ġurnata", - "Room use": "Użu tal-kmamar" + "Room use": "Użu tal-kmamar", + "Publish as draft lessons": "Ippubblika bħala lezzjonijiet abbozz", + "Draft lessons written: {count}": "Lezzjonijiet abbozz miktuba: {count}", + "Could not publish the scenario.": "Ix-xenarju ma setax jiġi ppubblikat." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 0923b884..4f2085b5 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Perjodi ħielsa, l-għalliema kollha", "Most free periods of one teacher": "L-aktar perjodi ħielsa ta' għalliem wieħed", "Most lessons of one group on a day": "L-aktar lezzjonijiet ta' grupp wieħed f'ġurnata", - "Room use": "Użu tal-kmamar" + "Room use": "Użu tal-kmamar", + "Publish as draft lessons": "Ippubblika bħala lezzjonijiet abbozz", + "Draft lessons written: {count}": "Lezzjonijiet abbozz miktuba: {count}", + "Could not publish the scenario.": "Ix-xenarju ma setax jiġi ppubblikat." }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index ec424e05..3eeb970f 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Hulltimer, alle lærere", "Most free periods of one teacher": "Flest hulltimer for én lærer", "Most lessons of one group on a day": "Flest timer for én gruppe på en dag", - "Room use": "Rombruk" + "Room use": "Rombruk", + "Publish as draft lessons": "Publiser som utkasttimer", + "Draft lessons written: {count}": "Utkasttimer skrevet: {count}", + "Could not publish the scenario.": "Kunne ikke publisere scenarioet." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 952b1532..d80c00a0 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Hulltimer, alle lærere", "Most free periods of one teacher": "Flest hulltimer for én lærer", "Most lessons of one group on a day": "Flest timer for én gruppe på en dag", - "Room use": "Rombruk" + "Room use": "Rombruk", + "Publish as draft lessons": "Publiser som utkasttimer", + "Draft lessons written: {count}": "Utkasttimer skrevet: {count}", + "Could not publish the scenario.": "Kunne ikke publisere scenarioet." }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index fe670ef9..66442d5f 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1081,7 +1081,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Tussenuren, alle docenten", "Most free periods of one teacher": "Meeste tussenuren van één docent", "Most lessons of one group on a day": "Meeste lessen van één groep op een dag", - "Room use": "Lokaalgebruik" + "Room use": "Lokaalgebruik", + "Publish as draft lessons": "Publiceren als conceptlessen", + "Draft lessons written: {count}": "Conceptlessen geschreven: {count}", + "Could not publish the scenario.": "Kon het scenario niet publiceren." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 204a9b4c..2ad02681 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1080,7 +1080,10 @@ "Free periods between lessons, all teachers": "Tussenuren, alle docenten", "Most free periods of one teacher": "Meeste tussenuren van één docent", "Most lessons of one group on a day": "Meeste lessen van één groep op een dag", - "Room use": "Lokaalgebruik" + "Room use": "Lokaalgebruik", + "Publish as draft lessons": "Publiceren als conceptlessen", + "Draft lessons written: {count}": "Conceptlessen geschreven: {count}", + "Could not publish the scenario.": "Kon het scenario niet publiceren." }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index fe6d4503..7be51099 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Okienka, wszyscy nauczyciele", "Most free periods of one teacher": "Najwięcej okienek jednego nauczyciela", "Most lessons of one group on a day": "Najwięcej lekcji jednej grupy w dniu", - "Room use": "Wykorzystanie sal" + "Room use": "Wykorzystanie sal", + "Publish as draft lessons": "Opublikuj jako lekcje robocze", + "Draft lessons written: {count}": "Zapisane lekcje robocze: {count}", + "Could not publish the scenario.": "Nie udało się opublikować scenariusza." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index b680fc86..a968f2f5 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Okienka, wszyscy nauczyciele", "Most free periods of one teacher": "Najwięcej okienek jednego nauczyciela", "Most lessons of one group on a day": "Najwięcej lekcji jednej grupy w dniu", - "Room use": "Wykorzystanie sal" + "Room use": "Wykorzystanie sal", + "Publish as draft lessons": "Opublikuj jako lekcje robocze", + "Draft lessons written: {count}": "Zapisane lekcje robocze: {count}", + "Could not publish the scenario.": "Nie udało się opublikować scenariusza." }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index d4bfef38..7650a91d 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Furos, todos os professores", "Most free periods of one teacher": "Mais furos de um professor", "Most lessons of one group on a day": "Mais aulas de um grupo num dia", - "Room use": "Ocupação das salas" + "Room use": "Ocupação das salas", + "Publish as draft lessons": "Publicar como aulas em rascunho", + "Draft lessons written: {count}": "Aulas em rascunho escritas: {count}", + "Could not publish the scenario.": "Não foi possível publicar o cenário." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index ae68e82f..fcdc357b 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Furos, todos os professores", "Most free periods of one teacher": "Mais furos de um professor", "Most lessons of one group on a day": "Mais aulas de um grupo num dia", - "Room use": "Ocupação das salas" + "Room use": "Ocupação das salas", + "Publish as draft lessons": "Publicar como aulas em rascunho", + "Draft lessons written: {count}": "Aulas em rascunho escritas: {count}", + "Could not publish the scenario.": "Não foi possível publicar o cenário." }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 0ba72f9b..5ef81e28 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Uras libras, tut ils magisters", "Most free periods of one teacher": "Il pli bler uras libras d'in magister", "Most lessons of one group on a day": "Il pli bler lecziuns d'ina gruppa en in di", - "Room use": "Utilisaziun da las stanzas" + "Room use": "Utilisaziun da las stanzas", + "Publish as draft lessons": "Publitgar sco lecziuns sbozs", + "Draft lessons written: {count}": "Lecziuns sbozs scrittas: {count}", + "Could not publish the scenario.": "Impussibel da publitgar il scenari." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index e180f576..8b19ae58 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Uras libras, tut ils magisters", "Most free periods of one teacher": "Il pli bler uras libras d'in magister", "Most lessons of one group on a day": "Il pli bler lecziuns d'ina gruppa en in di", - "Room use": "Utilisaziun da las stanzas" + "Room use": "Utilisaziun da las stanzas", + "Publish as draft lessons": "Publitgar sco lecziuns sbozs", + "Draft lessons written: {count}": "Lecziuns sbozs scrittas: {count}", + "Could not publish the scenario.": "Impussibel da publitgar il scenari." }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 34294665..08a30997 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Ferestre, toți profesorii", "Most free periods of one teacher": "Cele mai multe ferestre ale unui profesor", "Most lessons of one group on a day": "Cele mai multe ore ale unei grupe într-o zi", - "Room use": "Utilizarea sălilor" + "Room use": "Utilizarea sălilor", + "Publish as draft lessons": "Publică drept ore ciornă", + "Draft lessons written: {count}": "Ore ciornă scrise: {count}", + "Could not publish the scenario.": "Scenariul nu a putut fi publicat." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index c22ef28d..66108b40 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Ferestre, toți profesorii", "Most free periods of one teacher": "Cele mai multe ferestre ale unui profesor", "Most lessons of one group on a day": "Cele mai multe ore ale unei grupe într-o zi", - "Room use": "Utilizarea sălilor" + "Room use": "Utilizarea sălilor", + "Publish as draft lessons": "Publică drept ore ciornă", + "Draft lessons written: {count}": "Ore ciornă scrise: {count}", + "Could not publish the scenario.": "Scenariul nu a putut fi publicat." }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index 31be706c..827988fb 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Окна, все учителя", "Most free periods of one teacher": "Больше всего окон у одного учителя", "Most lessons of one group on a day": "Больше всего уроков одной группы за день", - "Room use": "Использование кабинетов" + "Room use": "Использование кабинетов", + "Publish as draft lessons": "Опубликовать как черновики уроков", + "Draft lessons written: {count}": "Записано черновиков уроков: {count}", + "Could not publish the scenario.": "Не удалось опубликовать сценарий." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index 9f45e2c6..04933667 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Окна, все учителя", "Most free periods of one teacher": "Больше всего окон у одного учителя", "Most lessons of one group on a day": "Больше всего уроков одной группы за день", - "Room use": "Использование кабинетов" + "Room use": "Использование кабинетов", + "Publish as draft lessons": "Опубликовать как черновики уроков", + "Draft lessons written: {count}": "Записано черновиков уроков: {count}", + "Could not publish the scenario.": "Не удалось опубликовать сценарий." }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index f29ba0d0..e03a838d 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Voľné hodiny, všetci učitelia", "Most free periods of one teacher": "Najviac voľných hodín jedného učiteľa", "Most lessons of one group on a day": "Najviac hodín jednej skupiny za deň", - "Room use": "Využitie učební" + "Room use": "Využitie učební", + "Publish as draft lessons": "Zverejniť ako koncepty hodín", + "Draft lessons written: {count}": "Zapísané koncepty hodín: {count}", + "Could not publish the scenario.": "Scenár sa nepodarilo zverejniť." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index e188baeb..746c7d86 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Voľné hodiny, všetci učitelia", "Most free periods of one teacher": "Najviac voľných hodín jedného učiteľa", "Most lessons of one group on a day": "Najviac hodín jednej skupiny za deň", - "Room use": "Využitie učební" + "Room use": "Využitie učební", + "Publish as draft lessons": "Zverejniť ako koncepty hodín", + "Draft lessons written: {count}": "Zapísané koncepty hodín: {count}", + "Could not publish the scenario.": "Scenár sa nepodarilo zverejniť." }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index d9334f8f..46d6dd2c 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Proste ure, vsi učitelji", "Most free periods of one teacher": "Največ prostih ur enega učitelja", "Most lessons of one group on a day": "Največ ur ene skupine na dan", - "Room use": "Raba učilnic" + "Room use": "Raba učilnic", + "Publish as draft lessons": "Objavi kot osnutke ur", + "Draft lessons written: {count}": "Zapisani osnutki ur: {count}", + "Could not publish the scenario.": "Scenarija ni bilo mogoče objaviti." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index e84ddee4..0f864d03 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Proste ure, vsi učitelji", "Most free periods of one teacher": "Največ prostih ur enega učitelja", "Most lessons of one group on a day": "Največ ur ene skupine na dan", - "Room use": "Raba učilnic" + "Room use": "Raba učilnic", + "Publish as draft lessons": "Objavi kot osnutke ur", + "Draft lessons written: {count}": "Zapisani osnutki ur: {count}", + "Could not publish the scenario.": "Scenarija ni bilo mogoče objaviti." }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 7ebb9ea4..08112e0c 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Orë bosh, të gjithë mësuesit", "Most free periods of one teacher": "Më shumë orë bosh të një mësuesi", "Most lessons of one group on a day": "Më shumë orë mësimi të një grupi në një ditë", - "Room use": "Përdorimi i sallave" + "Room use": "Përdorimi i sallave", + "Publish as draft lessons": "Publiko si orë draft", + "Draft lessons written: {count}": "Orë draft të shkruara: {count}", + "Could not publish the scenario.": "Skenari nuk u publikua dot." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index 6c81e1fa..c243e5c8 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Orë bosh, të gjithë mësuesit", "Most free periods of one teacher": "Më shumë orë bosh të një mësuesi", "Most lessons of one group on a day": "Më shumë orë mësimi të një grupi në një ditë", - "Room use": "Përdorimi i sallave" + "Room use": "Përdorimi i sallave", + "Publish as draft lessons": "Publiko si orë draft", + "Draft lessons written: {count}": "Orë draft të shkruara: {count}", + "Could not publish the scenario.": "Skenari nuk u publikua dot." }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index b5606317..73a69c4e 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Празни часови, сви наставници", "Most free periods of one teacher": "Највише празних часова једног наставника", "Most lessons of one group on a day": "Највише часова једне групе у дану", - "Room use": "Коришћење учионица" + "Room use": "Коришћење учионица", + "Publish as draft lessons": "Објави као нацрте часова", + "Draft lessons written: {count}": "Записани нацрти часова: {count}", + "Could not publish the scenario.": "Сценарио није могуће објавити." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 589b656e..a68593f6 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Празни часови, сви наставници", "Most free periods of one teacher": "Највише празних часова једног наставника", "Most lessons of one group on a day": "Највише часова једне групе у дану", - "Room use": "Коришћење учионица" + "Room use": "Коришћење учионица", + "Publish as draft lessons": "Објави као нацрте часова", + "Draft lessons written: {count}": "Записани нацрти часова: {count}", + "Could not publish the scenario.": "Сценарио није могуће објавити." }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index 32700244..a6d4455d 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Håltimmar, alla lärare", "Most free periods of one teacher": "Flest håltimmar för en lärare", "Most lessons of one group on a day": "Flest lektioner för en grupp en dag", - "Room use": "Salsanvändning" + "Room use": "Salsanvändning", + "Publish as draft lessons": "Publicera som utkastlektioner", + "Draft lessons written: {count}": "Utkastlektioner skrivna: {count}", + "Could not publish the scenario.": "Det gick inte att publicera scenariot." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index 5be81a4a..ff39122d 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Håltimmar, alla lärare", "Most free periods of one teacher": "Flest håltimmar för en lärare", "Most lessons of one group on a day": "Flest lektioner för en grupp en dag", - "Room use": "Salsanvändning" + "Room use": "Salsanvändning", + "Publish as draft lessons": "Publicera som utkastlektioner", + "Draft lessons written: {count}": "Utkastlektioner skrivna: {count}", + "Could not publish the scenario.": "Det gick inte att publicera scenariot." }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index d71f455b..cf66e3cf 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Boş saatler, tüm öğretmenler", "Most free periods of one teacher": "Bir öğretmenin en çok boş saati", "Most lessons of one group on a day": "Bir grubun bir günde en çok dersi", - "Room use": "Derslik kullanımı" + "Room use": "Derslik kullanımı", + "Publish as draft lessons": "Taslak ders olarak yayımla", + "Draft lessons written: {count}": "Yazılan taslak dersler: {count}", + "Could not publish the scenario.": "Senaryo yayımlanamadı." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 038e1e7a..0a8b159c 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Boş saatler, tüm öğretmenler", "Most free periods of one teacher": "Bir öğretmenin en çok boş saati", "Most lessons of one group on a day": "Bir grubun bir günde en çok dersi", - "Room use": "Derslik kullanımı" + "Room use": "Derslik kullanımı", + "Publish as draft lessons": "Taslak ders olarak yayımla", + "Draft lessons written: {count}": "Yazılan taslak dersler: {count}", + "Could not publish the scenario.": "Senaryo yayımlanamadı." }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index fea37ccf..9af55d92 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1068,7 +1068,10 @@ OC.L10N.register( "Free periods between lessons, all teachers": "Вікна, усі вчителі", "Most free periods of one teacher": "Найбільше вікон в одного вчителя", "Most lessons of one group on a day": "Найбільше уроків однієї групи за день", - "Room use": "Використання кабінетів" + "Room use": "Використання кабінетів", + "Publish as draft lessons": "Опублікувати як чернетки уроків", + "Draft lessons written: {count}": "Записано чернеток уроків: {count}", + "Could not publish the scenario.": "Не вдалося опублікувати сценарій." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 81f032e5..6d24244f 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1067,7 +1067,10 @@ "Free periods between lessons, all teachers": "Вікна, усі вчителі", "Most free periods of one teacher": "Найбільше вікон в одного вчителя", "Most lessons of one group on a day": "Найбільше уроків однієї групи за день", - "Room use": "Використання кабінетів" + "Room use": "Використання кабінетів", + "Publish as draft lessons": "Опублікувати як чернетки уроків", + "Draft lessons written: {count}": "Записано чернеток уроків: {count}", + "Could not publish the scenario.": "Не вдалося опублікувати сценарій." }, "plurals": {} } diff --git a/lib/Controller/TimetableScenarioController.php b/lib/Controller/TimetableScenarioController.php index 7d714f41..d3bb9ccb 100644 --- a/lib/Controller/TimetableScenarioController.php +++ b/lib/Controller/TimetableScenarioController.php @@ -30,6 +30,7 @@ use OCA\Planninq\Service\SettingsService; use OCA\Planninq\Service\TimetableGenerationService; use OCA\Planninq\Service\TimetableScenarioImporter; +use OCA\Planninq\Service\TimetableScenarioPublisher; use OCA\Planninq\Settings\AdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -39,10 +40,11 @@ use OCP\IRequest; /** - * The generate and import endpoints of timetable scenarios. + * The generate, import and publish endpoints of timetable scenarios. * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioController extends Controller { @@ -54,6 +56,7 @@ class TimetableScenarioController extends Controller { * @param IJobList $jobList Runs it in the background. * @param SettingsService $settingsService Tells whether the user is an admin. * @param TimetableScenarioImporter $importer Takes the current timetable into a scenario. + * @param TimetableScenarioPublisher $publisher Writes a scenario as draft lessons. * * @return void */ @@ -63,6 +66,7 @@ public function __construct( private readonly IJobList $jobList, private readonly SettingsService $settingsService, private readonly TimetableScenarioImporter $importer, + private readonly TimetableScenarioPublisher $publisher, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -115,4 +119,27 @@ public function importCurrent(string $id): JSONResponse { return new JSONResponse(['id' => $id, 'status' => $scenario['status'], 'metrics' => $scenario['metrics']]); }//end importCurrent() + /** + * Write a finished scenario's placements as draft lessons for every week of its window. + * + * @param string $id The scenario id. + * + * @return JSONResponse + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function publishDrafts(string $id): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can publish a timetable scenario.'], Http::STATUS_FORBIDDEN); + } + + try { + $result = $this->publisher->publishDrafts(id: $id); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(array_merge(['id' => $id], $result)); + }//end publishDrafts() }//end class diff --git a/lib/Service/TimetableScenarioPublisher.php b/lib/Service/TimetableScenarioPublisher.php new file mode 100644 index 00000000..330ebb38 --- /dev/null +++ b/lib/Service/TimetableScenarioPublisher.php @@ -0,0 +1,221 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use DateTimeImmutable; +use InvalidArgumentException; + +/** + * Publish a scenario as draft lessons. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ +class TimetableScenarioPublisher { + + /** + * The source system the generator's lessons are stored under. + */ + public const SOURCE = 'planninq-generator'; + + /** + * The days of the week in grid order, as offsets from Monday. + */ + private const DAY_OFFSETS = ['mon' => 0, 'tue' => 1, 'wed' => 2, 'thu' => 3, 'fri' => 4, 'sat' => 5, 'sun' => 6]; + + /** + * Constructor. + * + * @param TimetableScenarioStore $store Reads the scenario and the generator's lessons. + * @param TimetableSessionService $sessions Upserts the draft lessons. + * @param TimetableGridService $grid The period times. + * + * @return void + */ + public function __construct( + private readonly TimetableScenarioStore $store, + private readonly TimetableSessionService $sessions, + private readonly TimetableGridService $grid, + ) { + }//end __construct() + + /** + * Write the scenario's placements as draft lessons for every week of the window. + * + * Drafts from the generator in the window that belong to another scenario + * are removed first; a lesson of the window the generator already + * published refuses the whole publish. + * + * @param string $id The scenario id. + * + * @return array{created:int,updated:int,unchanged:int,removed:int,rejected:int} + * + * @throws InvalidArgumentException When the scenario cannot be published. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + public function publishDrafts(string $id): array { + $scenario = $this->store->load(id: $id); + if ($scenario === null + || ($scenario['source'] ?? '') !== 'generated' + || in_array(($scenario['status'] ?? ''), ['done', 'published'], true) === false + || ($scenario['placements'] ?? []) === [] + ) { + throw new InvalidArgumentException('Only a finished generated scenario with placements can be published.'); + } + + [$from, $to] = $this->window(scenario: $scenario); + $existing = $this->store->lessonsFrom(source: self::SOURCE, from: $from->format(DATE_ATOM), to: $to->format(DATE_ATOM)); + foreach ($existing as $lesson) { + if (($lesson['status'] ?? '') === 'scheduled') { + throw new InvalidArgumentException('Lessons of this window are already published from the generator. Nothing was written.'); + } + } + + $removed = 0; + foreach ($existing as $lesson) { + if (str_starts_with((string)($lesson['externalRef'] ?? ''), $id.':') === false) { + $this->store->deleteLesson(id: (string)$lesson['id']); + $removed++; + } + } + + $result = $this->sessions->upsert(sourceSystem: self::SOURCE, sessions: $this->rows(id: $id, scenario: $scenario, from: $from, to: $to)); + $this->store->save(id: $id, data: array_merge($scenario, ['status' => 'published', 'publishedAt' => (new DateTimeImmutable())->format(DATE_ATOM)])); + + return [ + 'created' => (int)($result['created'] ?? 0), + 'updated' => (int)($result['updated'] ?? 0), + 'unchanged' => (int)($result['unchanged'] ?? 0), + 'removed' => $removed, + 'rejected' => count((array)($result['rejected'] ?? [])), + ]; + }//end publishDrafts() + + /** + * The draft lesson rows of every week of the window, in the upsert contract's shape. + * + * @param string $id The scenario id. + * @param array $scenario The scenario. + * @param DateTimeImmutable $from The first day of the window. + * @param DateTimeImmutable $to The end of the last day of the window. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + public function rows(string $id, array $scenario, DateTimeImmutable $from, DateTimeImmutable $to): array { + $lessons = array_column((array)($scenario['input']['lessons'] ?? []), null, 'key'); + $times = array_column($this->grid->periodTimes(), null, 'key'); + $rows = []; + for ($monday = $this->monday(date: $from); $monday <= $to; $monday = $monday->modify('+7 days')) { + foreach ((array)$scenario['placements'] as $placement) { + $row = $this->row(id: $id, lesson: ($lessons[$placement['lesson']] ?? null), placement: $placement, monday: $monday, times: $times); + if ($row !== null && $row['startsAt'] >= $from->format(DATE_ATOM) && $row['startsAt'] <= $to->format(DATE_ATOM)) { + $rows[] = $row; + } + } + } + + return $rows; + }//end rows() + + /** + * One draft lesson, or null when the lesson or its period is unknown. + * + * @param string $id The scenario id. + * @param array|null $lesson The lesson. + * @param array $placement The placement. + * @param DateTimeImmutable $monday The Monday of the week. + * @param array $times Period times by key. + * + * @return array|null + */ + private function row(string $id, ?array $lesson, array $placement, DateTimeImmutable $monday, array $times): ?array { + $first = ($times[$placement['period']] ?? null); + if ($lesson === null || $first === null) { + return null; + } + + [$day, $number] = explode('-', $placement['period'], 2); + $last = ($times[$day.'-'.((int)$number + max(1, (int)($lesson['length'] ?? 1)) - 1)] ?? $first); + $date = $monday->modify('+'.self::DAY_OFFSETS[$day].' days'); + $row = [ + 'externalRef' => $id.':'.$lesson['key'].':'.$date->format('Y-m-d'), + 'subject' => (string)$lesson['subject'], + 'title' => (string)$lesson['subject'].' '.(string)$lesson['group'], + 'startsAt' => $this->at(date: $date, time: $first['start']), + 'endsAt' => $this->at(date: $date, time: $last['end']), + 'groupReference' => (string)$lesson['group'], + 'teacherUserId' => (string)$lesson['teacher'], + 'roomReference' => (string)$placement['room'], + 'status' => 'draft', + ]; + + return array_filter($row, static fn (string $value): bool => $value !== ''); + }//end row() + + /** + * A date at a time of day, as ISO 8601 in the server's time zone. + * + * @param DateTimeImmutable $date The date. + * @param string $time The time, as in `08:30`. + * + * @return string + */ + private function at(DateTimeImmutable $date, string $time): string { + [$hour, $minute] = array_map('intval', explode(':', $time, 2)); + return $date->setTime($hour, $minute)->format(DATE_ATOM); + }//end at() + + /** + * The window of a scenario: its first day at midnight and its last day just before midnight. + * + * @param array $scenario The scenario. + * + * @return array{0:DateTimeImmutable,1:DateTimeImmutable} + */ + private function window(array $scenario): array { + $from = DateTimeImmutable::createFromFormat('!Y-m-d', (string)($scenario['windowFrom'] ?? '')); + $to = DateTimeImmutable::createFromFormat('!Y-m-d', (string)($scenario['windowTo'] ?? '')); + if ($from === false || $to === false || $to < $from) { + throw new InvalidArgumentException('The scenario needs a window with a start before its end.'); + } + + return [$from, $to->setTime(23, 59, 59)]; + }//end window() + + /** + * The Monday of a date's week, at midnight. + * + * @param DateTimeImmutable $date The date. + * + * @return DateTimeImmutable + */ + private function monday(DateTimeImmutable $date): DateTimeImmutable { + return $date->modify('monday this week')->setTime(0, 0); + }//end monday() +}//end class diff --git a/lib/Service/TimetableScenarioStore.php b/lib/Service/TimetableScenarioStore.php index 100b1740..4871a00c 100644 --- a/lib/Service/TimetableScenarioStore.php +++ b/lib/Service/TimetableScenarioStore.php @@ -177,6 +177,49 @@ public function scheduledLessons(string $from, string $to): array { return $lessons; }//end scheduledLessons() + /** + * The lessons from one source that start in a window, drafts and scheduled, with their ids. + * + * @param string $source The source system. + * @param string $from The first moment, ISO 8601. + * @param string $to The last moment, ISO 8601. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + public function lessonsFrom(string $source, string $from, string $to): array { + $results = $this->objectService()->searchObjectsBySlug( + registerSlug: self::REGISTER, + schemaSlug: self::SESSION, + filters: ['sourceSystem' => $source, 'startsAt' => ['gte' => $from, 'lte' => $to], '_limit' => TimetableSessionQuery::MAX_LIMIT], + _rbac: false + ); + $lessons = []; + foreach ((new TimetableSessionRows())->listOf(results: $results) as $row) { + $data = (array)$this->dataOf(object: $row); + $starts = strtotime((string)($data['startsAt'] ?? '')); + if (($data['sourceSystem'] ?? '') === $source && $starts !== false && $starts >= strtotime($from) && $starts <= strtotime($to)) { + $lessons[] = $data; + } + } + + return $lessons; + }//end lessonsFrom() + + /** + * Delete one lesson. + * + * @param string $id The lesson id. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + public function deleteLesson(string $id): void { + $this->objectService()->deleteObject(uuid: $id, register: self::REGISTER, schema: self::SESSION, _rbac: false); + }//end deleteLesson() + /** * An object's data with its id, from an ObjectEntity or an array; null for anything else. * diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 4592e6c5..3c3c0bac 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -39,7 +39,7 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 8. Publish -- [ ] 8.1 `POST /api/timetable/scenarios/{id}/publish-drafts` (admin): upsert the placements as draft lessons for every week of the window (design decision 7). Verify: PHPUnit: every payload validated against the real `timetableSession` fragment; a second scenario for the same window replaces unpublished drafts and is refused when lessons are already published from `planninq-generator`. +- [x] 8.1 `POST /api/timetable/scenarios/{id}/publish-drafts` (admin): upsert the placements as draft lessons for every week of the window (design decision 7). Verify: PHPUnit: every payload validated against the real `timetableSession` fragment; a second scenario for the same window replaces unpublished drafts and is refused when lessons are already published from `planninq-generator`. Built (section 8 PR): `TimetableScenarioPublisher` (only a finished generated scenario; drafts of another scenario in the window removed first; the upsert through `TimetableSessionService`), endpoint in `TimetableScenarioController::publishDrafts`, button "Publish as draft lessons" on the scenario page; PHPUnit `TimetableScenarioPublisherTest` (4, the real session service and every payload validated with Opis), `TimetableScenarioControllerTest`, vitest `canPublishDrafts`. ## 9. Docs and verification diff --git a/src/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue index 3e55f6df..54f423e6 100644 --- a/src/components/TimetableScenarioSections.vue +++ b/src/components/TimetableScenarioSections.vue @@ -38,6 +38,16 @@ @click="takeCurrent"> {{ t('planninq', 'Take the current timetable') }} + + {{ t('planninq', 'Publish as draft lessons') }} + +

+ {{ t('planninq', 'Draft lessons written: {count}', { count: published }) }} +

@@ -136,12 +146,13 @@ * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 */ import { buildHeaders } from '@conduction/nextcloud-vue' import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' import { NcButton, NcLoadingIcon, NcProgressBar } from '@nextcloud/vue' -import { brokenHardRows, brokenSoftRows, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../utils/timetableScenarios.js' +import { brokenHardRows, brokenSoftRows, canPublishDrafts, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../utils/timetableScenarios.js' const POLL_MS = 5000 @@ -160,6 +171,7 @@ export default { loadError: '', starting: false, startError: '', + published: null, timer: null, } }, @@ -237,6 +249,16 @@ export default { return getCurrentUser()?.isAdmin === true && canTakeCurrentTimetable(this.scenario) }, + /** + * Whether this user may publish this scenario as draft lessons. + * + * @return {boolean} + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + canPublish() { + return getCurrentUser()?.isAdmin === true && canPublishDrafts(this.scenario) + }, + /** * The broken hard wishes (an imported timetable only). * @@ -347,6 +369,26 @@ export default { await this.load() }, + /** + * Write the placements as draft lessons for every week of the window; teachers review them in the draft review. + * + * @return {Promise} + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ + async publish() { + this.starting = true + this.startError = '' + const response = await fetch(generateUrl(`/apps/planninq/api/timetable/scenarios/${this.id}/publish-drafts`), { method: 'POST', headers: buildHeaders() }).catch(() => null) + this.starting = false + const body = await response?.json().catch(() => ({})) + if (!response?.ok) { + this.startError = body?.error || this.t('planninq', 'Could not publish the scenario.') + return + } + this.published = Number(body?.created ?? 0) + Number(body?.updated ?? 0) + Number(body?.unchanged ?? 0) + await this.load() + }, + /** * Why a lesson has no place: the hard wish, a time off the week grid, or no free period and room. * diff --git a/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js index 9a93f66d..0863d0b4 100644 --- a/src/utils/timetableScenarios.js +++ b/src/utils/timetableScenarios.js @@ -110,3 +110,15 @@ export function brokenHardRows(scenario) { export function canTakeCurrentTimetable(scenario) { return scenario?.source === 'imported' && !isRunning(scenario) && scenario?.status !== 'published' } + +/** + * Whether a scenario can be published as draft lessons: a finished generated one with placements. + * A scenario taken from the current timetable is already the timetable. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @param {object|null} scenario The scenario. + * @return {boolean} + */ +export function canPublishDrafts(scenario) { + return scenario?.source === 'generated' && scenario?.status === 'done' && (scenario?.placements ?? []).length > 0 +} diff --git a/tests/unit/Controller/TimetableScenarioControllerTest.php b/tests/unit/Controller/TimetableScenarioControllerTest.php index 1250ab53..65a01e5f 100644 --- a/tests/unit/Controller/TimetableScenarioControllerTest.php +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -29,6 +29,7 @@ use OCA\Planninq\Service\SettingsService; use OCA\Planninq\Service\TimetableGenerationService; use OCA\Planninq\Service\TimetableScenarioImporter; +use OCA\Planninq\Service\TimetableScenarioPublisher; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\BackgroundJob\IJobList; @@ -37,10 +38,11 @@ use ReflectionMethod; /** - * The generate and import endpoints. + * The generate, import and publish endpoints. * * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioControllerTest extends TestCase { @@ -51,10 +53,11 @@ class TimetableScenarioControllerTest extends TestCase { * @param TimetableGenerationService $generation The generation service. * @param IJobList $jobList The job list. * @param TimetableScenarioImporter $importer The importer, a stub when not given. + * @param TimetableScenarioPublisher $publisher The publisher, a stub when not given. * * @return TimetableScenarioController */ - private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList, ?TimetableScenarioImporter $importer=null): TimetableScenarioController { + private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList, ?TimetableScenarioImporter $importer=null, ?TimetableScenarioPublisher $publisher=null): TimetableScenarioController { $settings = $this->createMock(SettingsService::class); $settings->method('isCurrentUserAdmin')->willReturn($admin); return new TimetableScenarioController( @@ -62,7 +65,8 @@ private function controller(bool $admin, TimetableGenerationService $generation, generation: $generation, jobList: $jobList, settingsService: $settings, - importer: ($importer ?? $this->createMock(TimetableScenarioImporter::class)) + importer: ($importer ?? $this->createMock(TimetableScenarioImporter::class)), + publisher: ($publisher ?? $this->createMock(TimetableScenarioPublisher::class)) ); }//end controller() @@ -157,4 +161,45 @@ static function (string $id): array { self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $refused->getStatus()); self::assertStringContainsString(needle: 'no scheduled lessons', haystack: $refused->getData()['error']); }//end testAnAdminImportsOrIsToldWhyNot() + /** + * A non-admin cannot publish a scenario as drafts. + * + * @return void + */ + public function testANonAdminCannotPublish(): void { + $publisher = $this->createMock(TimetableScenarioPublisher::class); + $publisher->expects($this->never())->method('publishDrafts'); + + $response = $this->controller(admin: false, generation: $this->createMock(TimetableGenerationService::class), jobList: $this->createMock(IJobList::class), publisher: $publisher)->publishDrafts(id: 's-1'); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertNotEmpty((new ReflectionMethod(TimetableScenarioController::class, 'publishDrafts'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminCannotPublish() + + /** + * An admin gets the counts; a window already published is refused with 400 and the reason. + * + * @return void + */ + public function testAnAdminPublishesOrIsToldWhyNot(): void { + $publisher = $this->createMock(TimetableScenarioPublisher::class); + $publisher->method('publishDrafts')->willReturnCallback( + static function (string $id): array { + if ($id === 's-2') { + throw new InvalidArgumentException('Lessons of this window are already published from the generator. Nothing was written.'); + } + + return ['created' => 4, 'updated' => 0, 'unchanged' => 0, 'removed' => 0, 'rejected' => 0]; + } + ); + $controller = $this->controller(admin: true, generation: $this->createMock(TimetableGenerationService::class), jobList: $this->createMock(IJobList::class), publisher: $publisher); + + $response = $controller->publishDrafts(id: 's-1'); + self::assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + self::assertSame(expected: 4, actual: $response->getData()['created']); + + $refused = $controller->publishDrafts(id: 's-2'); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $refused->getStatus()); + self::assertStringContainsString(needle: 'already published', haystack: $refused->getData()['error']); + }//end testAnAdminPublishesOrIsToldWhyNot() }//end class diff --git a/tests/unit/Service/TimetableScenarioPublisherTest.php b/tests/unit/Service/TimetableScenarioPublisherTest.php new file mode 100644 index 00000000..b02812d9 --- /dev/null +++ b/tests/unit/Service/TimetableScenarioPublisherTest.php @@ -0,0 +1,297 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use InvalidArgumentException; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableScenarioPublisher; +use OCA\Planninq\Service\TimetableScenarioStore; +use OCA\Planninq\Service\TimetableSessionService; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * The real publisher, store and session service over an in-memory ObjectService. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + */ +class TimetableScenarioPublisherTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The fake ObjectService. + * + * @var object + */ + private object $objects; + + /** + * Two finished scenarios for the same two-week window. + * + * @return void + */ + protected function setUp(): void { + $this->objects = new class { + /** + * Stored objects by schema and id. + * + * @var array>> + */ + public array $stored = ['timetableSession' => [], 'timetableScenario' => []]; + + /** + * Saved lesson payloads. + * + * @var array> + */ + public array $lessonSaves = []; + + /** + * The next id. + * + * @var integer + */ + private int $next = 1; + + /** + * OpenRegister's find. + * + * @param string $id The id. + * + * @return array|null + */ + public function find(string $id, mixed ...$rest): ?array { + return ($this->stored[(string)$rest['schema']][$id] ?? null); + } + + /** + * OpenRegister's saveObject. + * + * @param array $object The data. + * + * @return array + */ + public function saveObject(array $object, mixed ...$rest): array { + $id = (string)($rest['uuid'] ?? ('n-'.$this->next++)); + if ($rest['schema'] === 'timetableSession') { + $this->lessonSaves[] = $object; + } + + $this->stored[(string)$rest['schema']][$id] = array_merge($object, ['id' => $id]); + return $this->stored[(string)$rest['schema']][$id]; + } + + /** + * OpenRegister's deleteObject. + * + * @param string $uuid The id. + * + * @return bool + */ + public function deleteObject(string $uuid, mixed ...$rest): bool { + unset($this->stored[(string)$rest['schema']][$uuid]); + return true; + } + + /** + * OpenRegister's searchObjectsBySlug: exact scalar filters and a startsAt window. + * + * @param string $registerSlug The register. + * @param string $schemaSlug The schema. + * @param array $filters The filters. + * + * @return array + */ + public function searchObjectsBySlug(string $registerSlug, string $schemaSlug, array $filters=[], mixed ...$rest): array { + $rows = array_filter( + ($this->stored[$schemaSlug] ?? []), + static function (array $row) use ($filters): bool { + foreach ($filters as $field => $value) { + if ($field === 'startsAt') { + $at = strtotime((string)$row['startsAt']); + if ($at < strtotime($value['gte']) || $at > strtotime($value['lte'])) { + return false; + } + + continue; + } + + if ($field[0] !== '_' && is_scalar($value) === true && ($row[$field] ?? null) !== $value) { + return false; + } + } + + return true; + } + ); + + return ['results' => array_values($rows)]; + } + }; + + $scenario = fn (string $id, array $placements): array => [ + 'id' => $id, + 'title' => 'Try '.$id, + 'source' => 'generated', + 'weekOf' => '2026-10-05', + 'windowFrom' => '2026-10-05', + 'windowTo' => '2026-10-16', + 'status' => 'done', + 'input' => [ + 'periods' => [], + 'rooms' => [], + 'wishes' => [], + 'lessons' => [ + ['key' => '3A:English:1', 'activity' => '3A:English', 'group' => '3A', 'subject' => 'English', 'teacher' => 'klaas', 'roomType' => 'classroom', 'length' => 1], + ['key' => '3B:Maths:1', 'activity' => '3B:Maths', 'group' => '3B', 'subject' => 'Maths', 'teacher' => 'noor', 'roomType' => 'classroom', 'length' => 2], + ], + ], + 'placements' => $placements, + ]; + $this->objects->stored['timetableScenario']['s-1'] = $scenario('s-1', [['lesson' => '3A:English:1', 'period' => 'mon-1', 'room' => 'B12'], ['lesson' => '3B:Maths:1', 'period' => 'wed-3', 'room' => 'B13']]); + $this->objects->stored['timetableScenario']['s-2'] = $scenario('s-2', [['lesson' => '3A:English:1', 'period' => 'tue-2', 'room' => 'B12']]); + }//end setUp() + + /** + * The publisher with the default grid. + * + * @return TimetableScenarioPublisher + */ + private function publisher(): TimetableScenarioPublisher { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->objects); + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback(static fn (string $app, string $key, string $default = ''): string => $default); + + return new TimetableScenarioPublisher( + store: new TimetableScenarioStore(container: $container), + sessions: new TimetableSessionService(container: $container, logger: $this->createMock(LoggerInterface::class)), + grid: new TimetableGridService(appConfig: $config) + ); + }//end publisher() + + /** + * The lessons stored from the generator, by external reference. + * + * @return array> + */ + private function generatorLessons(): array { + $lessons = array_filter($this->objects->stored['timetableSession'], static fn (array $row): bool => $row['sourceSystem'] === TimetableScenarioPublisher::SOURCE); + return array_column($lessons, null, 'externalRef'); + }//end generatorLessons() + + /** + * Scenario "Publish a scenario as drafts": every week of the window gets the pattern as drafts, + * each payload valid against the real timetableSession fragment, and the scenario is published. + * + * @return void + */ + public function testEveryWeekOfTheWindowGetsDrafts(): void { + $result = $this->publisher()->publishDrafts(id: 's-1'); + + self::assertSame(expected: 4, actual: $result['created']); + self::assertSame(expected: 0, actual: $result['rejected']); + $lessons = $this->generatorLessons(); + self::assertSame( + expected: ['s-1:3A:English:1:2026-10-05', 's-1:3A:English:1:2026-10-12', 's-1:3B:Maths:1:2026-10-07', 's-1:3B:Maths:1:2026-10-14'], + actual: array_keys($this->sortedKeys($lessons)) + ); + $maths = $lessons['s-1:3B:Maths:1:2026-10-07']; + self::assertSame(expected: 'draft', actual: $maths['status']); + self::assertSame(expected: '10:10', actual: date('H:i', strtotime($maths['startsAt']))); + self::assertSame(expected: '11:50', actual: date('H:i', strtotime($maths['endsAt']))); + foreach ($this->objects->lessonSaves as $payload) { + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableSession', payload: $payload)); + } + + self::assertSame(expected: 'published', actual: $this->objects->stored['timetableScenario']['s-1']['status']); + }//end testEveryWeekOfTheWindowGetsDrafts() + + /** + * A second scenario for the same window replaces the first one's unpublished drafts. + * + * @return void + */ + public function testASecondScenarioReplacesTheDrafts(): void { + $this->publisher()->publishDrafts(id: 's-1'); + $result = $this->publisher()->publishDrafts(id: 's-2'); + + self::assertSame(expected: 4, actual: $result['removed']); + self::assertSame(expected: ['s-2:3A:English:1:2026-10-06', 's-2:3A:English:1:2026-10-13'], actual: array_keys($this->sortedKeys($this->generatorLessons()))); + }//end testASecondScenarioReplacesTheDrafts() + + /** + * A window with a lesson the generator already published is refused, and nothing is written. + * + * @return void + */ + public function testAPublishedWindowIsRefused(): void { + $this->publisher()->publishDrafts(id: 's-1'); + $first = array_key_first($this->objects->stored['timetableSession']); + $this->objects->stored['timetableSession'][$first]['status'] = 'scheduled'; + $before = $this->objects->stored['timetableSession']; + + try { + $this->publisher()->publishDrafts(id: 's-2'); + self::fail('A published window was overwritten.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString(needle: 'already published', haystack: $e->getMessage()); + } + + self::assertSame(expected: $before, actual: $this->objects->stored['timetableSession']); + self::assertSame(expected: 'done', actual: $this->objects->stored['timetableScenario']['s-2']['status']); + }//end testAPublishedWindowIsRefused() + + /** + * A scenario taken from the current timetable is not published again as drafts. + * + * @return void + */ + public function testAnImportedScenarioIsRefused(): void { + $this->objects->stored['timetableScenario']['s-1']['source'] = 'imported'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('finished generated scenario'); + $this->publisher()->publishDrafts(id: 's-1'); + }//end testAnImportedScenarioIsRefused() + + /** + * An array sorted by key. + * + * @param array $rows The rows. + * + * @return array + */ + private function sortedKeys(array $rows): array { + ksort($rows); + return $rows; + }//end sortedKeys() +}//end class diff --git a/tests/vitest/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js index 43ad4aff..4c172041 100644 --- a/tests/vitest/timetableScenarios.spec.js +++ b/tests/vitest/timetableScenarios.spec.js @@ -4,7 +4,7 @@ * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 */ import { describe, expect, it } from 'vitest' -import { brokenHardRows, brokenSoftRows, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' +import { brokenHardRows, brokenSoftRows, canPublishDrafts, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' const scenario = { status: 'running', @@ -89,3 +89,15 @@ describe('an imported scenario (scenario: an imported timetable that breaks a ha expect(canTakeCurrentTimetable(null)).toBe(false) }) }) + +describe('publishing (scenario: publish a scenario as drafts)', () => { + it('offers publishing only for a finished generated scenario with placements', () => { + const done = { source: 'generated', status: 'done', placements: [{ lesson: 'a', period: 'mon-1', room: 'B12' }] } + expect(canPublishDrafts(done)).toBe(true) + expect(canPublishDrafts({ ...done, source: 'imported' })).toBe(false) + expect(canPublishDrafts({ ...done, placements: [] })).toBe(false) + expect(canPublishDrafts({ ...done, status: 'running' })).toBe(false) + expect(canPublishDrafts({ ...done, status: 'published' })).toBe(false) + expect(canPublishDrafts(null)).toBe(false) + }) +}) From d1686d00d3d7236534683e6109d44a36dd5b42eb Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 09:08:12 +0200 Subject: [PATCH 12/14] style(timetable): phpmd, a longer method name and no static date parsing in the publisher --- lib/Service/TimetableScenarioPublisher.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/Service/TimetableScenarioPublisher.php b/lib/Service/TimetableScenarioPublisher.php index 330ebb38..0b70b7bc 100644 --- a/lib/Service/TimetableScenarioPublisher.php +++ b/lib/Service/TimetableScenarioPublisher.php @@ -167,8 +167,8 @@ private function row(string $id, ?array $lesson, array $placement, DateTimeImmut 'externalRef' => $id.':'.$lesson['key'].':'.$date->format('Y-m-d'), 'subject' => (string)$lesson['subject'], 'title' => (string)$lesson['subject'].' '.(string)$lesson['group'], - 'startsAt' => $this->at(date: $date, time: $first['start']), - 'endsAt' => $this->at(date: $date, time: $last['end']), + 'startsAt' => $this->atTime(date: $date, time: $first['start']), + 'endsAt' => $this->atTime(date: $date, time: $last['end']), 'groupReference' => (string)$lesson['group'], 'teacherUserId' => (string)$lesson['teacher'], 'roomReference' => (string)$placement['room'], @@ -186,10 +186,10 @@ private function row(string $id, ?array $lesson, array $placement, DateTimeImmut * * @return string */ - private function at(DateTimeImmutable $date, string $time): string { + private function atTime(DateTimeImmutable $date, string $time): string { [$hour, $minute] = array_map('intval', explode(':', $time, 2)); return $date->setTime($hour, $minute)->format(DATE_ATOM); - }//end at() + }//end atTime() /** * The window of a scenario: its first day at midnight and its last day just before midnight. @@ -199,13 +199,13 @@ private function at(DateTimeImmutable $date, string $time): string { * @return array{0:DateTimeImmutable,1:DateTimeImmutable} */ private function window(array $scenario): array { - $from = DateTimeImmutable::createFromFormat('!Y-m-d', (string)($scenario['windowFrom'] ?? '')); - $to = DateTimeImmutable::createFromFormat('!Y-m-d', (string)($scenario['windowTo'] ?? '')); - if ($from === false || $to === false || $to < $from) { + $from = (string)($scenario['windowFrom'] ?? ''); + $to = (string)($scenario['windowTo'] ?? ''); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) !== 1 || preg_match('/^\d{4}-\d{2}-\d{2}$/', $to) !== 1 || $to < $from) { throw new InvalidArgumentException('The scenario needs a window with a start before its end.'); } - return [$from, $to->setTime(23, 59, 59)]; + return [new DateTimeImmutable($from.' 00:00:00'), new DateTimeImmutable($to.' 23:59:59')]; }//end window() /** From e291f914d4b5b1b46114ef29b3b431128c2eff09 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 09:20:27 +0200 Subject: [PATCH 13/14] docs(timetable): the generator's feature page, a Newman folder and e2e cases for every scenario --- docs/features/README.md | 1 + docs/features/timetable-generator.md | 73 +++++ .../changes/timetabling-generator/tasks.md | 4 +- tests/e2e/timetable-generator.spec.ts | 64 +++- .../planninq.postman_collection.json | 293 ++++++++++++++++++ 5 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 docs/features/timetable-generator.md diff --git a/docs/features/README.md b/docs/features/README.md index 9c813a73..01b42d69 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -16,3 +16,4 @@ Planninq is a project management app for Nextcloud, providing kanban boards, tas | Procest Integration | Link tasks and projects to Procest cases via caseReference and zaakUuid fields; case badges and links in the UI | VNG ZGW InterneTaak, Schema.org Action | [procest-integration.md](procest-integration.md) | | Finance import | Actual costs and commitments from the finance system land on the project with the same project number; unmatched lines wait in Beheer | OpenRegister financeLine, integriq mapping | [finance-import.md](finance-import.md) | | School timetable | Lessons delivered by a rostering system (Zermelo, Untis, Xedule, TimeEdit) through integriq, upserted by source and lesson id, read by learniq per group or teacher | Schema.org Event, iCalendar VEVENT (RFC 5545) | [school-timetable.md](school-timetable.md) | +| Timetable generator | Generate a school timetable that keeps every hard wish, take the current timetable into a scenario, compare two or three scenarios, publish one as draft lessons | Schema.org Event, iCalendar VEVENT (RFC 5545) | [timetable-generator.md](timetable-generator.md) | diff --git a/docs/features/timetable-generator.md b/docs/features/timetable-generator.md new file mode 100644 index 00000000..15cdbfd2 --- /dev/null +++ b/docs/features/timetable-generator.md @@ -0,0 +1,73 @@ +# Timetable generator + +Planninq makes a school timetable for you. You set the wishes, press Generate, and compare the result with the timetable you have now. When you like it, you publish it as draft lessons. Teachers review the drafts before anything goes live. + +Only admins run the generator. Everyone with the `planninq-timetable` read right sees the wishes and the scenarios. + +## Set the week grid + +An admin sets two values through `POST /apps/planninq/api/settings`. There is no screen for them yet. + +- `timetable_period_grid`: the school days and the periods of a day, each with a start and an end time. The default is Monday to Friday with eight periods of 50 minutes from 08:30. +- `timetable_generator_budget_minutes`: how many minutes one run may search. The default is 10. + +Planninq refuses a grid without days, and periods that overlap. + +## Give the generator its activities + +The generator needs to know what to place: which group gets which subject, from which teacher, how many lessons a week, and in which kind of room. + +- **With learniq installed**, learniq answers with the activities of its hour plan. +- **Without learniq**, an admin sends two CSV sheets to `POST /apps/planninq/api/timetable/input/upload`: `rooms` (reference, capacity, type) and `activities` (group, subject, teacher, lessons per week, lesson length, room type). + +## Set the wishes + +Go to Timetable, Wishes. A wish applies to a teacher, a group, a room or an activity. Pick its kind: + +| Kind | What it asks | +|---|---| +| Not on these periods | No lessons on the periods you tick. | +| Preferably not on these periods | Avoid the periods you tick if possible. | +| At most a number of lessons a day | Never more lessons a day than the limit. | +| No free periods between lessons | Keep the day without gaps. | +| Always the same room | Every lesson in one room. | + +A **hard** wish is always kept. A **soft** wish has a weight from 1 to 3. The generator breaks a heavy soft wish last. + +## Generate a scenario + +1. Go to Timetable, Scenarios and add a scenario. Choose source "generated", the first week (`weekOf`) and the window it covers. +2. Open it and press **Generate**. The run goes to the background and shows how much of the time budget it used. +3. When it is done, the page shows how many lessons have a place. + +A generated scenario never breaks a hard wish. A lesson that has no place without breaking one stays off the grid, and the page names the wish that blocked it. Loosen that wish or make it soft, then press **Generate again**. + +## Compare with the timetable you have now + +1. Add a scenario with source "imported" and the week you want to take. +2. Open it and press **Take the current timetable**. Planninq reads the scheduled lessons of that week and scores them the same way. Hard wishes the current timetable breaks are listed under "Broken hard wishes". +3. Back on Timetable, Scenarios, choose two or three scenarios under "Scenarios to compare". + +Each line shows the value of every scenario, with the best one in bold. Below it, the lessons that sit on another period or in another room. + +## Publish as draft lessons + +Open a generated scenario that is done and press **Publish as draft lessons**. Planninq writes its lessons as drafts for every week of the window, with source `planninq-generator`. + +- Drafts of an earlier scenario for the same window are replaced. +- If lessons of that window from the generator are already published, nothing is written and the page tells you why. + +Teachers then see their own drafts, and an admin publishes them through the usual draft review. See [school-timetable.md](school-timetable.md). + +## The endpoints + +| Call | What it does | +|---|---| +| `POST /apps/planninq/api/timetable/input/upload` | Stores the rooms and activities sheets. | +| `POST /apps/planninq/api/timetable/scenarios/{id}/generate` | Starts a run. Answers 202. | +| `POST /apps/planninq/api/timetable/scenarios/{id}/import` | Takes the scheduled lessons of the scenario's week. | +| `POST /apps/planninq/api/timetable/scenarios/{id}/publish-drafts` | Writes the placements as draft lessons. | + +All four are for admins only. Anyone else gets 403. + +Next: set your week grid and add your first hard wish. diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index 3c3c0bac..0e4f053c 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -43,5 +43,5 @@ DECISIONS.md row 17). Sections 5 onward assume the solver answer recorded in des ## 9. Docs and verification -- [ ] 9.1 `docs/features/timetable-generator.md`, strings in all 36 locales, Newman folder "Timetable generator". Verify: `npm run check:l10n`, Newman. -- [ ] 9.2 `openspec validate timetabling-generator --type change --strict` passes, and every scenario is covered by a test named above or carries an `@e2e exclude ` note. +- [x] 9.1 `docs/features/timetable-generator.md`, strings in all 36 locales, Newman folder "Timetable generator". Verify: `npm run check:l10n`, Newman. Built (section 9 PR): `docs/features/timetable-generator.md` (and a row in the features README), Newman folder "Timetable generator" (8 requests); every string of sections 3 to 8 is in all 36 locales (`npm run check:l10n` 0). +- [x] 9.2 `openspec validate timetabling-generator --type change --strict` passes, and every scenario is covered by a test named above or carries an `@e2e exclude ` note. Done in the section 9 PR: `openspec validate timetabling-generator --type change --strict` passes; every scenario has a Playwright case in `tests/e2e/timetable-wishes.spec.ts` or `tests/e2e/timetable-generator.spec.ts` (hydra gate-19) besides the unit tests named above. diff --git a/tests/e2e/timetable-generator.spec.ts b/tests/e2e/timetable-generator.spec.ts index 0400c49f..0c7c4228 100644 --- a/tests/e2e/timetable-generator.spec.ts +++ b/tests/e2e/timetable-generator.spec.ts @@ -2,11 +2,14 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * E2E coverage for a generator run (timetabling-generator, section 5) and the - * scenario comparison (section 7). + * E2E coverage for a generator run (timetabling-generator, section 5), the + * scenario page's lists, the scenario comparison (section 7) and publishing as + * drafts (section 8). * * @e2e timetable-generator::generate-a-scenario-that-respects-a-hard-wish * @e2e timetable-generator::compare-a-generated-and-an-imported-scenario + * @e2e timetable-generator::a-lesson-that-cannot-be-placed-is-listed-with-its-reason + * @e2e timetable-generator::publish-a-scenario-as-drafts * * The run itself is a queued job, so the test starts it through the endpoint, * runs the background jobs with `occ background-job:worker` when the harness @@ -116,4 +119,61 @@ test.describe('Timetable generator', () => { await expect(page.getByTestId('compare-hardWishesBroken-1')).not.toHaveClass(/scenario-compare__best/) await expect(page.getByTestId('scenario-compare-diff')).toContainText('3A:English:2') }) + test('a lesson that cannot be placed is listed with its reason', async ({ page }) => { + const response = await api.post(`${OBJECTS}/timetableScenario`, { + data: { + title: `Unplaced ${RUN}`, + source: 'generated', + weekOf: '2026-10-05', + windowFrom: '2026-10-05', + windowTo: '2026-10-09', + status: 'done', + input: { periods: ['mon-1'], rooms: [], lessons: [{ key: '3A:English:3', activity: '3A:English', group: '3A', subject: 'English', teacher: `klaas-${RUN}`, roomType: 'classroom', length: 1 }], wishes: [{ id: 'w-klaas', appliesTo: 'teacher', reference: `klaas-${RUN}`, kind: 'unavailable', periods: ['mon-1'], strength: 'hard' }], source: 'csv' }, + placements: [], + unplaced: [{ lesson: '3A:English:3', wish: 'w-klaas', reason: 'hardWishPeriods' }], + brokenWishes: [], + metrics: { lessons: 1, placed: 0, unplaced: 1 }, + }, + }) + const body = await response.json() + const id = body.id ?? body['@self']?.id + created.push(`${OBJECTS}/timetableScenario/${id}`) + + await page.goto(`${PLANNINQ_ROOT}timetable/scenarios/${id}`) + const unplaced = page.getByTestId('scenario-unplaced') + await expect(unplaced).toContainText('3A:English:3') + await expect(unplaced).toContainText(`klaas-${RUN}`) + }) + + test('publish a scenario as drafts', async ({ page }) => { + const response = await api.post(`${OBJECTS}/timetableScenario`, { + data: { + title: `Publish ${RUN}`, + source: 'generated', + weekOf: '2027-03-01', + windowFrom: '2027-03-01', + windowTo: '2027-03-12', + status: 'done', + input: { periods: ['mon-1'], rooms: [], lessons: [{ key: `G${RUN}:Art:1`, activity: `G${RUN}:Art`, group: `G${RUN}`, subject: 'Art', teacher: `piet-${RUN}`, roomType: 'classroom', length: 1 }], wishes: [], source: 'csv' }, + placements: [{ lesson: `G${RUN}:Art:1`, period: 'mon-1', room: 'B12' }], + unplaced: [], + brokenWishes: [], + metrics: { lessons: 1, placed: 1, unplaced: 0 }, + }, + }) + const body = await response.json() + const id = body.id ?? body['@self']?.id + created.push(`${OBJECTS}/timetableScenario/${id}`) + + await page.goto(`${PLANNINQ_ROOT}timetable/scenarios/${id}`) + await page.getByTestId('scenario-publish').click() + await expect(page.getByTestId('scenario-published')).toContainText('2') + + const drafts = await (await api.get(`${OBJECTS}/timetableSession?sourceSystem=planninq-generator&_limit=100`)).json() + const mine = (drafts.results ?? []).filter((row: { externalRef?: string }) => String(row.externalRef ?? '').startsWith(`${id}:`)) + expect(mine.map((row: { status: string }) => row.status)).toEqual(['draft', 'draft']) + for (const row of mine) { + created.push(`${OBJECTS}/timetableSession/${row.id ?? row['@self']?.id}`) + } + }) }) diff --git a/tests/integration/planninq.postman_collection.json b/tests/integration/planninq.postman_collection.json index 481a6081..a248a83b 100644 --- a/tests/integration/planninq.postman_collection.json +++ b/tests/integration/planninq.postman_collection.json @@ -51,6 +51,16 @@ "key": "release_task_id", "value": "", "type": "string" + }, + { + "key": "tt_wish_id", + "value": "", + "type": "string" + }, + { + "key": "tt_scenario_id", + "value": "", + "type": "string" } ], "item": [ @@ -1700,6 +1710,289 @@ ] } ] + }, + { + "name": "Timetable generator", + "description": "timetabling-generator: the input sheets, a hard wish, a generated scenario whose run is queued with the uploaded lessons, and the refusals of import on a generated scenario and of publish on an unfinished one. The run itself is a background job, so its result is covered by PHPUnit (LocalSearchSolverTest, TimetableGenerationServiceTest) and the nightly Playwright run.", + "item": [ + { + "name": "upload the rooms and activities sheets", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/planninq/api/timetable/input/upload", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\"rooms\": \"reference,capacity,type\\nNWM-B12,30,classroom\", \"activities\": \"group,subject,teacher,lessons per week,lesson length,room type\\nNWM-3A,English,newman-klaas,2,1,classroom\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('sheets stored', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201]);", + "});" + ] + } + } + ] + }, + { + "name": "add a hard wish", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/openregister/api/objects/planninq/timetableWish", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\"appliesTo\": \"teacher\", \"reference\": \"newman-klaas\", \"kind\": \"unavailable\", \"periods\": [\"wed-5\", \"wed-6\"], \"strength\": \"hard\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('wish created', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201]);", + " const json = pm.response.json();", + " pm.collectionVariables.set('tt_wish_id', json.id || (json['@self'] || {}).id);", + "});" + ] + } + } + ] + }, + { + "name": "add a generated scenario", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/openregister/api/objects/planninq/timetableScenario", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\"title\": \"Newman generated\", \"source\": \"generated\", \"weekOf\": \"2026-10-05\", \"windowFrom\": \"2026-10-05\", \"windowTo\": \"2026-10-16\", \"status\": \"queued\"}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('scenario created', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201]);", + " const json = pm.response.json();", + " pm.collectionVariables.set('tt_scenario_id', json.id || (json['@self'] || {}).id);", + "});" + ] + } + } + ] + }, + { + "name": "generate starts a background run", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/planninq/api/timetable/scenarios/{{tt_scenario_id}}/generate", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('run queued with the uploaded lessons', function () {", + " pm.expect(pm.response.code).to.eql(202);", + " const json = pm.response.json();", + " pm.expect(json.status).to.eql('queued');", + " pm.expect(json.lessons).to.be.above(0);", + "});" + ] + } + } + ] + }, + { + "name": "a generated scenario does not take the current timetable", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/planninq/api/timetable/scenarios/{{tt_scenario_id}}/import", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('refused with the reason', function () {", + " pm.expect(pm.response.code).to.eql(400);", + " pm.expect(pm.response.json().error).to.include('imported scenario');", + "});" + ] + } + } + ] + }, + { + "name": "a scenario that is not finished is not published", + "request": { + "method": "POST", + "url": "{{base_url}}/index.php/apps/planninq/api/timetable/scenarios/{{tt_scenario_id}}/publish-drafts", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('refused with the reason', function () {", + " pm.expect(pm.response.code).to.eql(400);", + " pm.expect(pm.response.json().error).to.include('finished generated scenario');", + "});" + ] + } + } + ] + }, + { + "name": "remove the scenario", + "request": { + "method": "DELETE", + "url": "{{base_url}}/index.php/apps/openregister/api/objects/planninq/timetableScenario/{{tt_scenario_id}}", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('scenario removed', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 204]);", + "});" + ] + } + } + ] + }, + { + "name": "remove the wish", + "request": { + "method": "DELETE", + "url": "{{base_url}}/index.php/apps/openregister/api/objects/planninq/timetableWish/{{tt_wish_id}}", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('wish removed', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 204]);", + "});" + ] + } + } + ] + } + ] } ] } \ No newline at end of file From 2ebf34de6c1854fe9e9a91470a914a80fb25722d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 09:21:41 +0200 Subject: [PATCH 14/14] chore(openspec): archive timetabling-generator; spec tags and both matrix rows point at the archive --- .../GenerateTimetableScenario.php | 6 +- lib/Controller/SettingsController.php | 4 +- lib/Controller/TimetableInputController.php | 4 +- .../TimetableScenarioController.php | 12 ++-- lib/Event/TimetableActivitiesQueryEvent.php | 12 ++-- lib/Service/TimetableCsvParser.php | 18 ++--- lib/Service/TimetableGenerationService.php | 6 +- lib/Service/TimetableGridService.php | 20 +++--- lib/Service/TimetableInputBuilder.php | 14 ++-- lib/Service/TimetableScenarioImporter.php | 4 +- lib/Service/TimetableScenarioPublisher.php | 6 +- lib/Service/TimetableScenarioStore.php | 14 ++-- lib/Timetabling/LocalSearchSolver.php | 4 +- lib/Timetabling/PlacementOptions.php | 4 +- lib/Timetabling/SearchState.php | 14 ++-- lib/Timetabling/SeededRandom.php | 6 +- lib/Timetabling/SolverInput.php | 6 +- lib/Timetabling/SolverResult.php | 4 +- lib/Timetabling/TimetableScorer.php | 8 +-- lib/Timetabling/TimetableSolver.php | 4 +- lib/Timetabling/WeekGrid.php | 12 ++-- lib/Timetabling/WishChecker.php | 8 +-- .../design.md | 0 .../proposal.md | 0 .../specs/timetable-generator/spec.md | 0 .../tasks.md | 0 openspec/parity/capabilities.json | 4 +- openspec/specs/timetable-generator/spec.md | 69 +++++++++++++++++++ src/components/TimetableScenarioCompare.vue | 10 +-- src/components/TimetableScenarioSections.vue | 42 +++++------ src/dialogs/TimetableWishDialog.vue | 28 ++++---- src/utils/scenarioCompare.js | 6 +- src/utils/timetableScenarios.js | 16 ++--- src/utils/timetableWishes.js | 18 ++--- .../Controller/SettingsControllerTest.php | 2 +- .../TimetableInputControllerTest.php | 6 +- .../TimetableScenarioControllerTest.php | 6 +- tests/unit/Service/TimetableCsvParserTest.php | 8 +-- .../TimetableGenerationServiceTest.php | 2 +- .../unit/Service/TimetableGridServiceTest.php | 6 +- .../Service/TimetableInputBuilderTest.php | 8 +-- .../Service/TimetableScenarioImporterTest.php | 2 +- .../TimetableScenarioPublisherTest.php | 2 +- .../Settings/PlanninqRegisterSchemaTest.php | 8 +-- .../Timetabling/LocalSearchSolverTest.php | 2 +- .../unit/Timetabling/TimetableScorerTest.php | 2 +- tests/vitest/scenarioCompare.spec.js | 2 +- tests/vitest/timetableScenarios.spec.js | 2 +- tests/vitest/timetableWishes.spec.js | 2 +- 49 files changed, 256 insertions(+), 187 deletions(-) rename openspec/changes/{timetabling-generator => archive/2026-09-30-timetabling-generator}/design.md (100%) rename openspec/changes/{timetabling-generator => archive/2026-09-30-timetabling-generator}/proposal.md (100%) rename openspec/changes/{timetabling-generator => archive/2026-09-30-timetabling-generator}/specs/timetable-generator/spec.md (100%) rename openspec/changes/{timetabling-generator => archive/2026-09-30-timetabling-generator}/tasks.md (100%) create mode 100644 openspec/specs/timetable-generator/spec.md diff --git a/lib/BackgroundJob/GenerateTimetableScenario.php b/lib/BackgroundJob/GenerateTimetableScenario.php index a3d75afd..810357b1 100644 --- a/lib/BackgroundJob/GenerateTimetableScenario.php +++ b/lib/BackgroundJob/GenerateTimetableScenario.php @@ -33,7 +33,7 @@ /** * Runs one step of a timetable scenario run. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ class GenerateTimetableScenario extends QueuedJob { @@ -61,7 +61,7 @@ public function __construct( * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ protected function run($argument): void { $this->runStep(argument: $argument); @@ -74,7 +74,7 @@ protected function run($argument): void { * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function runStep(mixed $argument): bool { $id = ''; diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 4027fe38..08c3a24e 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -75,7 +75,7 @@ public function __construct( * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-24-annotate-planix/tasks.md#task-4 - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function index(): JSONResponse { if ($this->userSession->getUser() === null) { @@ -96,7 +96,7 @@ public function index(): JSONResponse { * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-24-annotate-planix/tasks.md#task-4 - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function create(): JSONResponse { if ($this->settingsService->isCurrentUserAdmin() === false) { diff --git a/lib/Controller/TimetableInputController.php b/lib/Controller/TimetableInputController.php index cff85fe1..8cd5cca3 100644 --- a/lib/Controller/TimetableInputController.php +++ b/lib/Controller/TimetableInputController.php @@ -39,7 +39,7 @@ /** * Takes the uploaded rooms and activities sheets. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ class TimetableInputController extends Controller { @@ -71,7 +71,7 @@ public function __construct( * * @return JSONResponse 200 with the counts; 400 with the refused lines; 403 for a non-admin. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ #[AuthorizedAdminSetting(settings: AdminSettings::class)] public function upload(): JSONResponse { diff --git a/lib/Controller/TimetableScenarioController.php b/lib/Controller/TimetableScenarioController.php index d3bb9ccb..5c6aa3a4 100644 --- a/lib/Controller/TimetableScenarioController.php +++ b/lib/Controller/TimetableScenarioController.php @@ -42,9 +42,9 @@ /** * The generate, import and publish endpoints of timetable scenarios. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioController extends Controller { @@ -78,7 +78,7 @@ public function __construct( * * @return JSONResponse * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ #[AuthorizedAdminSetting(settings: AdminSettings::class)] public function generate(string $id): JSONResponse { @@ -103,7 +103,7 @@ public function generate(string $id): JSONResponse { * * @return JSONResponse * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ #[AuthorizedAdminSetting(settings: AdminSettings::class)] public function importCurrent(string $id): JSONResponse { @@ -126,7 +126,7 @@ public function importCurrent(string $id): JSONResponse { * * @return JSONResponse * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ #[AuthorizedAdminSetting(settings: AdminSettings::class)] public function publishDrafts(string $id): JSONResponse { diff --git a/lib/Event/TimetableActivitiesQueryEvent.php b/lib/Event/TimetableActivitiesQueryEvent.php index 6dac14cc..72b74d83 100644 --- a/lib/Event/TimetableActivitiesQueryEvent.php +++ b/lib/Event/TimetableActivitiesQueryEvent.php @@ -40,7 +40,7 @@ * An activity row: group, subject, teacher (a Nextcloud user id), lessonsPerWeek, * lessonLength (in periods) and roomType. A room row: reference, capacity, type. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ class TimetableActivitiesQueryEvent extends Event { @@ -90,7 +90,7 @@ public function __construct( * * @return string * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function getAcademicYear(): string { return $this->academicYear; @@ -105,7 +105,7 @@ public function getAcademicYear(): string { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function answer(string $app, array $activities, array $rooms): void { $this->answeredBy = $app; @@ -118,7 +118,7 @@ public function answer(string $app, array $activities, array $rooms): void { * * @return array>|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function getActivities(): ?array { return $this->activities; @@ -129,7 +129,7 @@ public function getActivities(): ?array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function getRooms(): array { return $this->rooms; @@ -140,7 +140,7 @@ public function getRooms(): array { * * @return string|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function getAnsweredBy(): ?string { return $this->answeredBy; diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php index 0c00df08..6cf3e5fe 100644 --- a/lib/Service/TimetableCsvParser.php +++ b/lib/Service/TimetableCsvParser.php @@ -33,7 +33,7 @@ * `header`, `empty`, `whole-number`, `unknown-room-type`. The header check * ignores case and surrounding spaces; a comma or a semicolon separates columns. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ class TimetableCsvParser { @@ -72,7 +72,7 @@ class TimetableCsvParser { * * @return array{rows:array,errors:array} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function parseRooms(string $csv): array { $rows = []; @@ -97,7 +97,7 @@ public function parseRooms(string $csv): array { * * @return array{rows:array>,errors:array} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function parseActivities(string $csv, array $roomTypes): array { $rows = []; @@ -133,7 +133,7 @@ public function parseActivities(string $csv, array $roomTypes): array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function records(string $csv, array $columns, array &$errors): array { $lines = preg_split('/\r\n|\r|\n/', trim($csv)); @@ -167,7 +167,7 @@ private function records(string $csv, array $columns, array &$errors): array { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function cells(string $line): array { $separator = ','; @@ -187,7 +187,7 @@ private function cells(string $line): array { * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function filled(array $cells, int $line, array &$errors): bool { $filled = true; @@ -211,7 +211,7 @@ private function filled(array $cells, int $line, array &$errors): bool { * * @return int|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function wholeNumber(string $value, int $line, string $field, array &$errors): ?int { if (preg_match('/^\d+$/', $value) === 1 && (int)$value >= 1) { @@ -235,7 +235,7 @@ private function wholeNumber(string $value, int $line, string $field, array &$er * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function knownRoomType(string $type, array $roomTypes, int $line, array &$errors): bool { if ($type === '' || in_array($type, $roomTypes, true) === true) { @@ -257,7 +257,7 @@ private function knownRoomType(string $type, array $roomTypes, int $line, array * * @return array{line:int,field:string,code:string,message:string} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function error(int $line, string $field, string $code, string $message): array { return ['line' => $line, 'field' => $field, 'code' => $code, 'message' => $message]; diff --git a/lib/Service/TimetableGenerationService.php b/lib/Service/TimetableGenerationService.php index 132bd6ad..40624231 100644 --- a/lib/Service/TimetableGenerationService.php +++ b/lib/Service/TimetableGenerationService.php @@ -34,7 +34,7 @@ /** * Queue and step a generated timetable scenario. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ class TimetableGenerationService { @@ -75,7 +75,7 @@ public function __construct( * * @throws InvalidArgumentException When the scenario does not exist, is imported or published, or has no activities. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function queue(string $id): array { $scenario = $this->store->load(id: $id); @@ -120,7 +120,7 @@ public function queue(string $id): array { * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function step(string $id): bool { $scenario = $this->store->load(id: $id); diff --git a/lib/Service/TimetableGridService.php b/lib/Service/TimetableGridService.php index d3c33c44..e3878a8c 100644 --- a/lib/Service/TimetableGridService.php +++ b/lib/Service/TimetableGridService.php @@ -36,7 +36,7 @@ * A grid is JSON: {"days": ["mon", ...], "periods": [{"start": "08:30", "end": "09:20"}, ...]}. * A period key is the day and the period number from 1, as in `mon-3`. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ class TimetableGridService { @@ -103,7 +103,7 @@ public function __construct( * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function settings(): array { return [ @@ -119,7 +119,7 @@ public function settings(): array { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function save(array $data): void { $normalised = [ @@ -138,7 +138,7 @@ public function save(array $data): void { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function periodKeys(): array { $grid = (array)json_decode(($this->normaliseGrid(raw: $this->settings()[self::GRID_KEY]) ?? self::DEFAULT_GRID), true); @@ -158,7 +158,7 @@ public function periodKeys(): array { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ public function periodTimes(): array { $grid = (array)json_decode(($this->normaliseGrid(raw: $this->settings()[self::GRID_KEY]) ?? self::DEFAULT_GRID), true); @@ -189,7 +189,7 @@ public function periodTimes(): array { * * @return string|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function normaliseGrid(string $raw): ?string { $grid = json_decode($raw, true); @@ -213,7 +213,7 @@ public function normaliseGrid(string $raw): ?string { * * @return string|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function normaliseBudget(string $raw): ?string { $trimmed = trim($raw); @@ -236,7 +236,7 @@ public function normaliseBudget(string $raw): ?string { * * @return array|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ private function days(mixed $value): ?array { if (is_array($value) === false || $value === []) { @@ -263,7 +263,7 @@ private function days(mixed $value): ?array { * * @return array|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ private function periods(mixed $value): ?array { if (is_array($value) === false || $value === []) { @@ -297,7 +297,7 @@ private function periods(mixed $value): ?array { * * @return string|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ private function time(mixed $value): ?string { if (is_string($value) === false || preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', trim($value)) !== 1) { diff --git a/lib/Service/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php index 8c77b780..adfa99a8 100644 --- a/lib/Service/TimetableInputBuilder.php +++ b/lib/Service/TimetableInputBuilder.php @@ -36,7 +36,7 @@ /** * Builds the SolverInput of a generator run. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ class TimetableInputBuilder { @@ -92,7 +92,7 @@ public function __construct( * * @return SolverInput * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function build(string $academicYear, array $wishes): SolverInput { $periods = $this->grid->periodKeys(); @@ -126,7 +126,7 @@ public function build(string $academicYear, array $wishes): SolverInput { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function storeUpload(array $rooms, array $activities): void { $this->appConfig->setValueString(Application::APP_ID, self::CSV_ROOMS_KEY, (string)json_encode(array_values($rooms)), lazy: true); @@ -140,7 +140,7 @@ public function storeUpload(array $rooms, array $activities): void { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ private function lessons(array $activities): array { $lessons = []; @@ -170,8 +170,8 @@ private function lessons(array $activities): array { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ public function solverWish(array $wish): array { $out = ['id' => (string)($wish['id'] ?? ($wish['@self']['id'] ?? ''))]; @@ -191,7 +191,7 @@ public function solverWish(array $wish): array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ private function stored(string $key): array { $rows = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '[]', lazy: true), true); diff --git a/lib/Service/TimetableScenarioImporter.php b/lib/Service/TimetableScenarioImporter.php index 81d00d22..f5bbb649 100644 --- a/lib/Service/TimetableScenarioImporter.php +++ b/lib/Service/TimetableScenarioImporter.php @@ -36,7 +36,7 @@ /** * Snapshot the current timetable into a scenario. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ class TimetableScenarioImporter { @@ -67,7 +67,7 @@ public function __construct( * * @throws InvalidArgumentException When the scenario does not exist, is not imported, or its week has no lessons. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ public function importInto(string $id): array { $scenario = $this->store->load(id: $id); diff --git a/lib/Service/TimetableScenarioPublisher.php b/lib/Service/TimetableScenarioPublisher.php index 0b70b7bc..0d972b89 100644 --- a/lib/Service/TimetableScenarioPublisher.php +++ b/lib/Service/TimetableScenarioPublisher.php @@ -32,7 +32,7 @@ /** * Publish a scenario as draft lessons. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioPublisher { @@ -75,7 +75,7 @@ public function __construct( * * @throws InvalidArgumentException When the scenario cannot be published. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ public function publishDrafts(string $id): array { $scenario = $this->store->load(id: $id); @@ -125,7 +125,7 @@ public function publishDrafts(string $id): array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ public function rows(string $id, array $scenario, DateTimeImmutable $from, DateTimeImmutable $to): array { $lessons = array_column((array)($scenario['input']['lessons'] ?? []), null, 'key'); diff --git a/lib/Service/TimetableScenarioStore.php b/lib/Service/TimetableScenarioStore.php index 4871a00c..74eeb501 100644 --- a/lib/Service/TimetableScenarioStore.php +++ b/lib/Service/TimetableScenarioStore.php @@ -35,7 +35,7 @@ * Written with RBAC off: the store is reached only from the admin-only * generate endpoint and from the queued job, which runs without a user. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ class TimetableScenarioStore { @@ -88,7 +88,7 @@ public function __construct( * * @return array|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function load(string $id): ?array { try { @@ -110,7 +110,7 @@ public function load(string $id): ?array { * * @throws RuntimeException When OpenRegister refuses it. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function save(string $id, array $data): void { unset($data['@self'], $data['id']); @@ -125,7 +125,7 @@ public function save(string $id, array $data): void { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ public function wishes(): array { $results = $this->objectService()->searchObjectsBySlug( @@ -153,7 +153,7 @@ public function wishes(): array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ public function scheduledLessons(string $from, string $to): array { $results = $this->objectService()->searchObjectsBySlug( @@ -186,7 +186,7 @@ public function scheduledLessons(string $from, string $to): array { * * @return array> * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ public function lessonsFrom(string $source, string $from, string $to): array { $results = $this->objectService()->searchObjectsBySlug( @@ -214,7 +214,7 @@ public function lessonsFrom(string $source, string $from, string $to): array { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ public function deleteLesson(string $id): void { $this->objectService()->deleteObject(uuid: $id, register: self::REGISTER, schema: self::SESSION, _rbac: false); diff --git a/lib/Timetabling/LocalSearchSolver.php b/lib/Timetabling/LocalSearchSolver.php index 8436ac91..b59c8910 100644 --- a/lib/Timetabling/LocalSearchSolver.php +++ b/lib/Timetabling/LocalSearchSolver.php @@ -30,7 +30,7 @@ /** * Greedy construction, simulated annealing, and a repair pass for hard wishes. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ final class LocalSearchSolver implements TimetableSolver { @@ -72,7 +72,7 @@ public function __construct( * * @return SolverResult * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function solve(SolverInput $input, int $seconds, int $seed, array $start=[]): SolverResult { $deadline = (microtime(true) + max(1, $seconds)); diff --git a/lib/Timetabling/PlacementOptions.php b/lib/Timetabling/PlacementOptions.php index ae22a401..7f4324b9 100644 --- a/lib/Timetabling/PlacementOptions.php +++ b/lib/Timetabling/PlacementOptions.php @@ -29,7 +29,7 @@ /** * The options of every lesson, and the hard wishes that narrowed them. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ final class PlacementOptions { @@ -52,7 +52,7 @@ public function __construct( * * @return array{options:array}>>,blockers:array>} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function build(SolverInput $input): array { $grid = new WeekGrid(periods: $input->periods); diff --git a/lib/Timetabling/SearchState.php b/lib/Timetabling/SearchState.php index 58024dd8..e87d0615 100644 --- a/lib/Timetabling/SearchState.php +++ b/lib/Timetabling/SearchState.php @@ -28,7 +28,7 @@ /** * The placements of a search and the periods they keep busy. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ final class SearchState { @@ -71,7 +71,7 @@ public function __construct(private readonly array $lessons) { * * @return bool Whether it was placed. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function place(string $key, array $option): bool { if ($this->fits(key: $key, option: $option) === false) { @@ -98,7 +98,7 @@ public function place(string $key, array $option): bool { * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function fits(string $key, array $option): bool { foreach ($this->marks(key: $key, option: $option) as $mark) { @@ -117,7 +117,7 @@ public function fits(string $key, array $option): bool { * * @return array{period:string,room:string,periods:array}|null The option it had. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function remove(string $key): ?array { $option = ($this->placed[$key] ?? null); @@ -144,7 +144,7 @@ public function remove(string $key): ?array { * * @return array{period:string,room:string,periods:array}|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function option(string $key): ?array { return ($this->placed[$key] ?? null); @@ -159,7 +159,7 @@ public function option(string $key): ?array { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function onDay(string $field, string $reference, string $day): int { return ($this->days[$field."\n".$reference."\n".$day] ?? 0); @@ -170,7 +170,7 @@ public function onDay(string $field, string $reference, string $day): int { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function placements(): array { $list = []; diff --git a/lib/Timetabling/SeededRandom.php b/lib/Timetabling/SeededRandom.php index 0f6901b3..434cbb87 100644 --- a/lib/Timetabling/SeededRandom.php +++ b/lib/Timetabling/SeededRandom.php @@ -28,7 +28,7 @@ /** * Deterministic pseudo-random numbers from a seed. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ final class SeededRandom { @@ -62,7 +62,7 @@ public function __construct(int $seed) { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function below(int $max): int { return ($this->next() % max(1, $max)); @@ -73,7 +73,7 @@ public function below(int $max): int { * * @return float * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function fraction(): float { return (($this->next() - 1) / (self::MODULUS - 1)); diff --git a/lib/Timetabling/SolverInput.php b/lib/Timetabling/SolverInput.php index acab68ee..e8c2fe2f 100644 --- a/lib/Timetabling/SolverInput.php +++ b/lib/Timetabling/SolverInput.php @@ -29,7 +29,7 @@ /** * The input of one generator run. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ final class SolverInput { @@ -60,7 +60,7 @@ public function __construct( * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function isEmpty(): bool { return $this->lessons === []; @@ -71,7 +71,7 @@ public function isEmpty(): bool { * * @return array{periods:array,rooms:array>,lessons:array>,wishes:array>,source:string} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function toArray(): array { return [ diff --git a/lib/Timetabling/SolverResult.php b/lib/Timetabling/SolverResult.php index 14366dba..4da01e4c 100644 --- a/lib/Timetabling/SolverResult.php +++ b/lib/Timetabling/SolverResult.php @@ -28,7 +28,7 @@ /** * The outcome of one solver run. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ final class SolverResult { @@ -59,7 +59,7 @@ public function __construct( * * @return array{placements:array>,unplaced:array>,brokenWishes:array>,metrics:array} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function toScenario(): array { return [ diff --git a/lib/Timetabling/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php index d19dfb2d..05bdf94e 100644 --- a/lib/Timetabling/TimetableScorer.php +++ b/lib/Timetabling/TimetableScorer.php @@ -34,7 +34,7 @@ * and `outOfGrid` (a lesson that starts outside the grid or runs off the day). * A broken soft wish costs its weight (1 when unset) for every lesson that breaks it. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ final class TimetableScorer { @@ -83,7 +83,7 @@ public function __construct( * * @return array{metrics:array,brokenWishes:array>,clashes:array>} * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function score(SolverInput $input, array $placements): array { $grid = new WeekGrid(periods: $input->periods); @@ -115,7 +115,7 @@ public function score(SolverInput $input, array $placements): array { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public static function cost(array $metrics): int { return ((int)$metrics['clashes'] * self::CLASH_COST) @@ -132,7 +132,7 @@ public static function cost(array $metrics): int { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function costOf(array $metrics): int { return self::cost(metrics: $metrics); diff --git a/lib/Timetabling/TimetableSolver.php b/lib/Timetabling/TimetableSolver.php index dbae5afc..b8027778 100644 --- a/lib/Timetabling/TimetableSolver.php +++ b/lib/Timetabling/TimetableSolver.php @@ -28,7 +28,7 @@ /** * Places the lessons of a solver input on the week grid. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ interface TimetableSolver { @@ -42,7 +42,7 @@ interface TimetableSolver { * * @return SolverResult * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ public function solve(SolverInput $input, int $seconds, int $seed, array $start=[]): SolverResult; }//end interface diff --git a/lib/Timetabling/WeekGrid.php b/lib/Timetabling/WeekGrid.php index 196044f6..59e9390e 100644 --- a/lib/Timetabling/WeekGrid.php +++ b/lib/Timetabling/WeekGrid.php @@ -28,7 +28,7 @@ /** * Days and period numbers of a list of period keys such as `mon-3`. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ final class WeekGrid { @@ -57,7 +57,7 @@ public function __construct(private readonly array $periods) { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function keys(): array { return $this->periods; @@ -71,7 +71,7 @@ public function keys(): array { * * @return array|null * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function span(string $start, int $length): ?array { $day = $this->day(key: $start); @@ -96,7 +96,7 @@ public function span(string $start, int $length): ?array { * * @return string * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function day(string $key): string { return explode('-', $key, 2)[0]; @@ -109,7 +109,7 @@ public function day(string $key): string { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function number(string $key): int { return (int)(explode('-', $key, 2)[1] ?? 0); @@ -122,7 +122,7 @@ public function number(string $key): int { * * @return int * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function gaps(array $numbers): int { if ($numbers === []) { diff --git a/lib/Timetabling/WishChecker.php b/lib/Timetabling/WishChecker.php index cf95890e..66916ac3 100644 --- a/lib/Timetabling/WishChecker.php +++ b/lib/Timetabling/WishChecker.php @@ -31,7 +31,7 @@ * A placed lesson (a slot) is: `lesson` (the input lesson), `room`, `periods` * (the period keys it takes), `day` and `order` (the grid position of its first period). * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ final class WishChecker { @@ -53,7 +53,7 @@ final class WishChecker { * * @return array * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function breakingLessons(array $wish, array $slots, WeekGrid $grid): array { $mine = array_values( @@ -82,7 +82,7 @@ public function breakingLessons(array $wish, array $slots, WeekGrid $grid): arra * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function forbids(array $wish, array $lesson, array $periods, string $room): bool { if (in_array(($wish['kind'] ?? ''), ['unavailable', 'avoid'], true) === false) { @@ -101,7 +101,7 @@ public function forbids(array $wish, array $lesson, array $periods, string $room * * @return bool * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ public function applies(array $wish, array $lesson, string $room): bool { $reference = (string)($wish['reference'] ?? ''); diff --git a/openspec/changes/timetabling-generator/design.md b/openspec/changes/archive/2026-09-30-timetabling-generator/design.md similarity index 100% rename from openspec/changes/timetabling-generator/design.md rename to openspec/changes/archive/2026-09-30-timetabling-generator/design.md diff --git a/openspec/changes/timetabling-generator/proposal.md b/openspec/changes/archive/2026-09-30-timetabling-generator/proposal.md similarity index 100% rename from openspec/changes/timetabling-generator/proposal.md rename to openspec/changes/archive/2026-09-30-timetabling-generator/proposal.md diff --git a/openspec/changes/timetabling-generator/specs/timetable-generator/spec.md b/openspec/changes/archive/2026-09-30-timetabling-generator/specs/timetable-generator/spec.md similarity index 100% rename from openspec/changes/timetabling-generator/specs/timetable-generator/spec.md rename to openspec/changes/archive/2026-09-30-timetabling-generator/specs/timetable-generator/spec.md diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md similarity index 100% rename from openspec/changes/timetabling-generator/tasks.md rename to openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 4fec5696..4f066ab7 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8821,7 +8821,7 @@ "state": "built", "evidence": "Compare: src/components/TimetableScenarioCompare.vue on the TimetableScenarios index page (below-header slot) sets two or three finished scenarios, generated or taken from the current timetable (TimetableScenarioImporter, POST /api/timetable/scenarios/{id}/import), side by side on the metrics TimetableScorer stored on each (lessons placed and unplaced, clashes, hard and soft wishes broken, weighted soft penalty, teacher gaps, lessons per day, room use), marks the best value per line and lists the lessons whose period or room differs (src/utils/scenarioCompare.js). Tests: vitest scenarioCompare.spec.js, PHPUnit TimetableScenarioImporterTest (an imported timetable that breaks a hard wish shows it in metrics), Playwright timetable-generator.spec.ts compare case.", "owner": "ConductionNL/planninq", - "change": "openspec/changes/timetabling-generator", + "change": "openspec/changes/archive/2026-09-30-timetabling-generator", "readOn": "2026-09-30" }, "provider": "learniq", @@ -9016,7 +9016,7 @@ "state": "built", "evidence": "Wishes: timetableWish schema (appliesTo, reference, kind, periods, limit, strength hard or soft, weight 1 to 3) edited on the TimetableWishes index page with src/dialogs/TimetableWishDialog.vue (period picker on the week grid). Generator: lib/Timetabling/LocalSearchSolver.php behind TimetableSolver (greedy most-constrained-first, simulated annealing on TimetableScorer, a repair pass so a generated scenario never breaks a hard wish; a lesson it cannot place names the blocking hard wish), run by POST /api/timetable/scenarios/{id}/generate and the GenerateTimetableScenario queued job in 60-second steps up to the time budget; TimetableScenarioDetail page shows unplaced lessons and broken soft wishes. Tests: LocalSearchSolverTest (feasible week keeps the hard wish, impossible hard wish names itself, hard limit never broken, free soft wish kept, same seed same result, 600 lessons inside 60 seconds validated against the real timetableScenario fragment), TimetableGenerationServiceTest, TimetableScenarioControllerTest, vitest timetableWishes and timetableScenarios.", "owner": "ConductionNL/planninq", - "change": "openspec/changes/timetabling-generator", + "change": "openspec/changes/archive/2026-09-30-timetabling-generator", "readOn": "2026-09-30" }, "provider": "learniq", diff --git a/openspec/specs/timetable-generator/spec.md b/openspec/specs/timetable-generator/spec.md new file mode 100644 index 00000000..1c592226 --- /dev/null +++ b/openspec/specs/timetable-generator/spec.md @@ -0,0 +1,69 @@ +# timetable-generator Specification + +## Purpose +Planninq generates a school timetable from the activities (learniq or uploaded sheets) and the wishes a timetabler sets, hard or soft. A generated scenario never breaks a hard wish. The current timetable can be taken into a scenario and scored the same way, two or three scenarios are compared side by side, and a chosen generated scenario is published as draft lessons through the existing draft review. Built by change archive/2026-09-30-timetabling-generator (DECISIONS rows 17 and 21: a PHP local search behind the `TimetableSolver` interface). + +## Requirements + +### Requirement: A timetabler marks each wish as hard or soft + +The system MUST let a timetabler record a wish on a teacher, a group, a room or one activity, and +MUST store for each wish whether it is hard (the generator must respect it) or soft (the generator +should respect it), with a weight of 1 to 3 for a soft wish. Only admins MAY write wishes; members of +`planninq-timetable` MAY read them. Tier: V1. + +#### Scenario: A timetabler marks a wish as hard + +- **GIVEN** the teacher `klaas` cannot teach on Wednesday afternoon +- **WHEN** the timetabler adds the wish "not on these periods" for `klaas` on Wednesday periods 5 to 8, strength hard +- **THEN** the wish is listed under `klaas` as hard +- **AND** a soft wish "preferably not on Friday period 8" for `klaas` is listed with its weight + +### Requirement: A generated timetable never breaks a hard wish + +The system MUST generate a scenario in which no teacher, group or room has two lessons in the same +period and no hard wish is broken. A lesson that cannot be placed without breaking a hard wish MUST +be listed as unplaced with the hard wish that blocked it. The system SHOULD keep soft wishes and MUST +report every soft wish it broke with its weight. Tier: V1. + +#### Scenario: Generate a scenario that respects a hard wish + +- **GIVEN** activities for group 3A and the hard wish that `klaas` does not teach on Wednesday periods 5 to 8 +- **WHEN** the timetabler generates a scenario +- **THEN** the scenario has no lesson of `klaas` on Wednesday periods 5 to 8 +- **AND** it reports 0 broken hard wishes + +#### Scenario: A lesson that cannot be placed is listed with its reason + +- **GIVEN** a teacher whose hard wishes leave fewer free periods than their lessons +- **WHEN** the timetabler generates a scenario +- **THEN** the lessons that do not fit are listed as unplaced +- **AND** each names the hard wish that blocked it + +### Requirement: A timetabler compares scenarios before publishing one + +The system MUST keep several scenarios per week pattern, generated or made from the current +timetable, and MUST show two or three of them side by side with the same measures: lessons placed and +unplaced, hard and soft wishes broken, teacher gaps, lessons per day and room use, marking the best +value per measure and listing the lessons that differ. Tier: V1. + +#### Scenario: Compare a generated and an imported scenario + +- **GIVEN** a scenario made from the current timetable and a generated scenario for the same week +- **WHEN** the timetabler opens the compare page with both +- **THEN** each measure shows both values with the better one marked +- **AND** the lessons whose period or room differ are listed + +### Requirement: Publishing a scenario uses the draft review + +The system MUST publish a chosen scenario by writing its lessons as draft `timetableSession` rows with +`sourceSystem` `planninq-generator` for every week of the scenario's window, so the existing draft +review and publish flow applies, and MUST refuse to replace lessons of that window that are already +published from that source. Tier: V1. + +#### Scenario: Publish a scenario as drafts + +- **GIVEN** a generated scenario for the weeks of 5 to 30 October +- **WHEN** the admin publishes it +- **THEN** its lessons exist as drafts for every week of that window +- **AND** a teacher sees their own draft lessons and nobody else does until the drafts are published diff --git a/src/components/TimetableScenarioCompare.vue b/src/components/TimetableScenarioCompare.vue index 96f536f2..f63223d6 100644 --- a/src/components/TimetableScenarioCompare.vue +++ b/src/components/TimetableScenarioCompare.vue @@ -81,7 +81,7 @@ * side by side with the best value per line marked, then the lessons whose * period or room differs. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ import { buildHeaders } from '@conduction/nextcloud-vue' import { generateUrl } from '@nextcloud/router' @@ -108,7 +108,7 @@ export default { * The metric rows. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ rows() { return compareRows(this.chosen) @@ -118,7 +118,7 @@ export default { * The lessons that differ. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ diff() { return lessonDiff(this.chosen) @@ -129,7 +129,7 @@ export default { * Read the scenarios that have a result. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ async mounted() { const response = await fetch(generateUrl('/apps/openregister/api/objects/planninq/timetableScenario?_limit=100'), { headers: buildHeaders() }).catch(() => null) @@ -143,7 +143,7 @@ export default { * * @param {string} key The metric. * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ lineLabel(key) { return { diff --git a/src/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue index 54f423e6..0803b53d 100644 --- a/src/components/TimetableScenarioSections.vue +++ b/src/components/TimetableScenarioSections.vue @@ -144,9 +144,9 @@ * an admin takes the current timetable instead, and its broken hard wishes * are listed as well. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ import { buildHeaders } from '@conduction/nextcloud-vue' import { getCurrentUser } from '@nextcloud/auth' @@ -181,7 +181,7 @@ export default { * The scenario id from the route. * * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ id() { return String(this.$route?.params?.id ?? '') @@ -191,7 +191,7 @@ export default { * Whether a run is going. * * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ running() { return isRunning(this.scenario) @@ -201,7 +201,7 @@ export default { * The share of the time budget used. * * @return {number} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ percent() { return progressPercent(this.scenario) @@ -211,7 +211,7 @@ export default { * The status in words. * * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ statusText() { const status = this.scenario?.status @@ -230,7 +230,7 @@ export default { * Whether this user may start a run: an admin, on a generated scenario that is not running or published. * * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ canGenerate() { return getCurrentUser()?.isAdmin === true @@ -243,7 +243,7 @@ export default { * Whether this user may take the current timetable into this scenario. * * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ canImport() { return getCurrentUser()?.isAdmin === true && canTakeCurrentTimetable(this.scenario) @@ -253,7 +253,7 @@ export default { * Whether this user may publish this scenario as draft lessons. * * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ canPublish() { return getCurrentUser()?.isAdmin === true && canPublishDrafts(this.scenario) @@ -263,7 +263,7 @@ export default { * The broken hard wishes (an imported timetable only). * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ brokenHard() { return brokenHardRows(this.scenario) @@ -273,7 +273,7 @@ export default { * The unplaced lessons. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ unplaced() { return unplacedRows(this.scenario) @@ -283,7 +283,7 @@ export default { * The broken soft wishes. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ broken() { return brokenSoftRows(this.scenario) @@ -294,7 +294,7 @@ export default { * Read the scenario. * * @return {void} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ mounted() { this.load() @@ -304,7 +304,7 @@ export default { * Stop reading. * * @return {void} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ beforeUnmount() { clearTimeout(this.timer) @@ -315,7 +315,7 @@ export default { * Read the scenario, and again in five seconds while a run is going. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ async load() { clearTimeout(this.timer) @@ -335,7 +335,7 @@ export default { * Start a run. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ async generate() { this.starting = true @@ -354,7 +354,7 @@ export default { * Take the scheduled lessons of the scenario's week into it. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ async takeCurrent() { this.starting = true @@ -373,7 +373,7 @@ export default { * Write the placements as draft lessons for every week of the window; teachers review them in the draft review. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ async publish() { this.starting = true @@ -394,7 +394,7 @@ export default { * * @param {{wish: object|null, reason: string}} row The unplaced row. * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ blockedBy(row) { if (row.wish) { @@ -408,7 +408,7 @@ export default { * * @param {object|null} wish The wish summary. * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ describe(wish) { const kinds = { diff --git a/src/dialogs/TimetableWishDialog.vue b/src/dialogs/TimetableWishDialog.vue index eef77483..32af7159 100644 --- a/src/dialogs/TimetableWishDialog.vue +++ b/src/dialogs/TimetableWishDialog.vue @@ -136,7 +136,7 @@ * `timetable_period_grid` setting. Saving goes through the page's own save * path (`confirm`), so the list refreshes as after any other edit. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ import { getDayNames } from '@nextcloud/l10n' import { NcButton, NcCheckboxRadioSwitch, NcDialog, NcLoadingIcon, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue' @@ -195,7 +195,7 @@ export default { * The week grid from the settings, the default grid until they are read. * * @return {object} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ grid() { return readGrid(useSettingsStore().settings?.timetable_period_grid ?? null) @@ -205,7 +205,7 @@ export default { * The rows of the period picker. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ rows() { return gridRows(this.grid, this.form.periods) @@ -215,7 +215,7 @@ export default { * Whether the chosen kind names periods. * * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ namesPeriods() { return PERIOD_KINDS.includes(this.form.kind) @@ -225,7 +225,7 @@ export default { * The problems, shown once the user tried to save. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ shownProblems() { return this.tried ? wishProblems(this.form) : [] @@ -235,7 +235,7 @@ export default { * The label of the reference field for what the wish applies to. * * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ referenceLabel() { return { @@ -250,7 +250,7 @@ export default { * The choices for what a wish applies to. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ appliesToOptions() { return [ @@ -265,7 +265,7 @@ export default { * The wish kinds. * * @return {Array} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ kindOptions() { return [ @@ -284,7 +284,7 @@ export default { * * @param {boolean} show Whether the dialog opens. * @return {void} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ show(show) { if (show) { @@ -298,7 +298,7 @@ export default { * Read the settings once, for the week grid. * * @return {void} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ created() { const settings = useSettingsStore() @@ -313,7 +313,7 @@ export default { * * @param {string} key The period key. * @return {void} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ togglePeriod(key) { this.form.periods = togglePeriod(this.form.periods, key) @@ -324,7 +324,7 @@ export default { * * @param {string} field The field. * @return {boolean} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ hasProblem(field) { return this.shownProblems.some((problem) => problem.field === field) @@ -335,7 +335,7 @@ export default { * * @param {string} day The day key. * @return {string} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ dayLabel(day) { const index = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].indexOf(day) @@ -346,7 +346,7 @@ export default { * Save the wish through the page, then close. * * @return {Promise} - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ async save() { this.tried = true diff --git a/src/utils/scenarioCompare.js b/src/utils/scenarioCompare.js index 06e98f43..95eb0728 100644 --- a/src/utils/scenarioCompare.js +++ b/src/utils/scenarioCompare.js @@ -33,7 +33,7 @@ export const MAX_COMPARED = 3 * One row per metric line with each scenario's value and the positions holding the best value. * A missing value is null and never the best; when every value is equal no value is marked. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 * @param {object[]} scenarios Two or three scenarios. * @return {Array<{key: string, values: Array, best: number[]}>} The rows. */ @@ -50,7 +50,7 @@ export function compareRows(scenarios) { /** * The positions of the best value, or none when there is no direction or no difference. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 * @param {Array} values The values. * @param {string|null} better `low`, `high` or null. * @return {number[]} The positions. @@ -68,7 +68,7 @@ export function bestPositions(values, better) { * The lessons whose period or room differs between the scenarios, in lesson order; a lesson a * scenario did not place shows null in that scenario's cell. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 * @param {object[]} scenarios Two or three scenarios. * @return {Array<{lesson: string, cells: Array<{period: string, room: string}|null>}>} The differing lessons. */ diff --git a/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js index 0863d0b4..8d08867f 100644 --- a/src/utils/timetableScenarios.js +++ b/src/utils/timetableScenarios.js @@ -8,7 +8,7 @@ /** * Whether a run is still going, so the page keeps reading the scenario. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 * @param {object|null} scenario The scenario. * @return {boolean} True while queued or running. */ @@ -19,7 +19,7 @@ export function isRunning(scenario) { /** * The share of the time budget spent, from 0 to 100. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 * @param {object|null} scenario The scenario. * @return {number} The percentage, whole. */ @@ -38,7 +38,7 @@ export function progressPercent(scenario) { /** * A short description of a wish, from the scenario's own input snapshot. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 * @param {object|null} wish The wish as the input keeps it. * @return {{appliesTo: string, reference: string, kind: string, periods: string[]}|null} The parts, or null when unknown. */ @@ -57,7 +57,7 @@ export function wishSummary(wish) { /** * The unplaced lessons with the hard wish that blocked each. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 * @param {object|null} scenario The scenario. * @return {Array<{lesson: string, wish: object|null, reason: string}>} One row per lesson. */ @@ -73,7 +73,7 @@ export function unplacedRows(scenario) { /** * The broken soft wishes, heaviest first, with the lessons that break them. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 * @param {object|null} scenario The scenario. * @return {Array<{wish: object|null, weight: number, lessons: string[]}>} One row per wish. */ @@ -89,7 +89,7 @@ export function brokenSoftRows(scenario) { * The broken hard wishes with the lessons that break them. A generated scenario has none; * an imported timetable can, and they are shown, not hidden. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 * @param {object|null} scenario The scenario. * @return {Array<{wish: object|null, lessons: string[]}>} One row per wish. */ @@ -103,7 +103,7 @@ export function brokenHardRows(scenario) { /** * Whether the current timetable can be taken into this scenario: an imported one that is not running or published. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 * @param {object|null} scenario The scenario. * @return {boolean} */ @@ -115,7 +115,7 @@ export function canTakeCurrentTimetable(scenario) { * Whether a scenario can be published as draft lessons: a finished generated one with placements. * A scenario taken from the current timetable is already the timetable. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 * @param {object|null} scenario The scenario. * @return {boolean} */ diff --git a/src/utils/timetableWishes.js b/src/utils/timetableWishes.js index b6b55667..bee60be7 100644 --- a/src/utils/timetableWishes.js +++ b/src/utils/timetableWishes.js @@ -40,7 +40,7 @@ export const DEFAULT_GRID = { /** * The key of one period. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {string} day A week day, as in `wed`. * @param {number} number The period number, from 1. * @return {string} The period key, as in `wed-5`. @@ -52,7 +52,7 @@ export function periodKey(day, number) { /** * The day and number of a period key, or null for a key that is not one. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {string} key A period key. * @return {{day: string, number: number}|null} The parts. */ @@ -67,7 +67,7 @@ export function parsePeriodKey(key) { /** * The stored grid setting as an object; the default grid when it is missing or unreadable. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {string|object|null} raw The `timetable_period_grid` setting. * @return {{days: string[], periods: Array<{start: string, end: string}>}} The grid. */ @@ -91,7 +91,7 @@ export function readGrid(raw) { /** * The rows of the period picker: one row per period number, one cell per day. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {{days: string[], periods: Array<{start: string, end: string}>}} grid The grid. * @param {string[]} selected The chosen period keys. * @return {Array<{number: number, start: string, end: string, cells: Array<{key: string, day: string, selected: boolean}>}>} The rows. @@ -112,7 +112,7 @@ export function gridRows(grid, selected = []) { /** * The chosen periods with one key switched on or off, in week order. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {string[]} selected The chosen period keys. * @param {string} key The key to switch. * @return {string[]} The new selection. @@ -125,7 +125,7 @@ export function togglePeriod(selected, key) { /** * Period keys in week order, then period order; keys that are not period keys are dropped. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {string[]} keys The keys. * @return {string[]} The sorted keys. */ @@ -140,7 +140,7 @@ export function sortPeriodKeys(keys) { /** * The form a wish is edited in, from a stored wish or empty for a new one. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {object|null} wish A stored wish. * @return {object} The form values. */ @@ -160,7 +160,7 @@ export function wishForm(wish) { /** * What is wrong with the form, as the field and a message key; empty when it can be saved. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {object} form The form values. * @return {Array<{field: string, message: string}>} The problems. */ @@ -182,7 +182,7 @@ export function wishProblems(form) { * The object saved for a form: periods only for a kind that names periods, a limit only * for "at most a number of lessons a day", and a weight only for a soft wish. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 * @param {object} form The form values. * @return {object} The wish to save. */ diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php index 9662b3d8..aab573e4 100644 --- a/tests/unit/Controller/SettingsControllerTest.php +++ b/tests/unit/Controller/SettingsControllerTest.php @@ -431,7 +431,7 @@ function (string $app, string $key, string $value): bool { /** * Task 1.2: the settings carry the week grid and budget, and an admin saves them; a refused grid is not stored. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 * * @return void */ diff --git a/tests/unit/Controller/TimetableInputControllerTest.php b/tests/unit/Controller/TimetableInputControllerTest.php index 8f15c62b..9111f307 100644 --- a/tests/unit/Controller/TimetableInputControllerTest.php +++ b/tests/unit/Controller/TimetableInputControllerTest.php @@ -98,7 +98,7 @@ private function sheets(): array { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testAnAdminUploadIsKept(): void { $response = $this->controller(body: $this->sheets(), admin: true)->upload(); @@ -114,7 +114,7 @@ public function testAnAdminUploadIsKept(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testANonAdminIsRefused(): void { $response = $this->controller(body: $this->sheets(), admin: false)->upload(); @@ -129,7 +129,7 @@ public function testANonAdminIsRefused(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testARefusedLineKeepsNothing(): void { $body = ['activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,Swimming,piet,1,1,pool\n"] + $this->sheets(); diff --git a/tests/unit/Controller/TimetableScenarioControllerTest.php b/tests/unit/Controller/TimetableScenarioControllerTest.php index 65a01e5f..346d67cc 100644 --- a/tests/unit/Controller/TimetableScenarioControllerTest.php +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -40,9 +40,9 @@ /** * The generate, import and publish endpoints. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioControllerTest extends TestCase { diff --git a/tests/unit/Service/TimetableCsvParserTest.php b/tests/unit/Service/TimetableCsvParserTest.php index 675fdbf0..0bd7353e 100644 --- a/tests/unit/Service/TimetableCsvParserTest.php +++ b/tests/unit/Service/TimetableCsvParserTest.php @@ -35,7 +35,7 @@ class TimetableCsvParserTest extends TestCase { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testAGoodSheetBecomesRows(): void { $parser = new TimetableCsvParser(); @@ -56,7 +56,7 @@ public function testAGoodSheetBecomesRows(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testAWrongHeaderIsRefused(): void { $result = (new TimetableCsvParser())->parseActivities(csv: "group,subject,teacher,hours\n3A,English,klaas,3\n", roomTypes: ['classroom']); @@ -73,7 +73,7 @@ public function testAWrongHeaderIsRefused(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testEachRefusalNamesItsLine(): void { $csv = implode( @@ -109,7 +109,7 @@ public function testEachRefusalNamesItsLine(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testARoomCapacityMustBeAWholeNumber(): void { $result = (new TimetableCsvParser())->parseRooms(csv: "reference,capacity,type\nr-101,thirty,classroom\n"); diff --git a/tests/unit/Service/TimetableGenerationServiceTest.php b/tests/unit/Service/TimetableGenerationServiceTest.php index 0bac2bf7..5018619e 100644 --- a/tests/unit/Service/TimetableGenerationServiceTest.php +++ b/tests/unit/Service/TimetableGenerationServiceTest.php @@ -48,7 +48,7 @@ /** * The real store over an in-memory ObjectService, the real input builder over uploaded sheets, the real solver. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.2 */ class TimetableGenerationServiceTest extends TestCase { use RegisterSchemaValidation; diff --git a/tests/unit/Service/TimetableGridServiceTest.php b/tests/unit/Service/TimetableGridServiceTest.php index 7c6aed4e..ba269c1a 100644 --- a/tests/unit/Service/TimetableGridServiceTest.php +++ b/tests/unit/Service/TimetableGridServiceTest.php @@ -63,7 +63,7 @@ function (string $app, string $key, string $value): bool { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function testTheDefaults(): void { $settings = $this->service()->settings(); @@ -82,7 +82,7 @@ public function testTheDefaults(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function testRefusedValuesAreNotStored(): void { $refused = [ @@ -115,7 +115,7 @@ public function testRefusedValuesAreNotStored(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.2 */ public function testAGoodGridIsStoredInWeekOrder(): void { $this->service()->save( diff --git a/tests/unit/Service/TimetableInputBuilderTest.php b/tests/unit/Service/TimetableInputBuilderTest.php index 4e9cbbc4..8c089ee3 100644 --- a/tests/unit/Service/TimetableInputBuilderTest.php +++ b/tests/unit/Service/TimetableInputBuilderTest.php @@ -97,7 +97,7 @@ private function hourPlan(): array { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function testAnAnsweredEventBecomesLessons(): void { [$activities, $rooms] = $this->hourPlan(); @@ -138,7 +138,7 @@ static function (TimetableActivitiesQueryEvent $event) use ($activities, $rooms, * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function testAnUnansweredEventWithoutUploadGivesTheReason(): void { $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); @@ -154,7 +154,7 @@ public function testAnUnansweredEventWithoutUploadGivesTheReason(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.2 */ public function testUploadedSheetsAreTheFallback(): void { [$activities, $rooms] = $this->hourPlan(); @@ -172,7 +172,7 @@ public function testUploadedSheetsAreTheFallback(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-2.1 */ public function testThePeriodsFollowTheStoredGrid(): void { $this->stored['timetable_period_grid'] = '{"days":["mon","wed"],"periods":[{"start":"08:00","end":"08:45"},{"start":"08:45","end":"09:30"}]}'; diff --git a/tests/unit/Service/TimetableScenarioImporterTest.php b/tests/unit/Service/TimetableScenarioImporterTest.php index 7fe50d43..5a5f6c40 100644 --- a/tests/unit/Service/TimetableScenarioImporterTest.php +++ b/tests/unit/Service/TimetableScenarioImporterTest.php @@ -40,7 +40,7 @@ /** * The real store over an in-memory ObjectService holding lessons, a wish and a scenario. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-6.1 */ class TimetableScenarioImporterTest extends TestCase { use RegisterSchemaValidation; diff --git a/tests/unit/Service/TimetableScenarioPublisherTest.php b/tests/unit/Service/TimetableScenarioPublisherTest.php index b02812d9..8022a969 100644 --- a/tests/unit/Service/TimetableScenarioPublisherTest.php +++ b/tests/unit/Service/TimetableScenarioPublisherTest.php @@ -41,7 +41,7 @@ /** * The real publisher, store and session service over an in-memory ObjectService. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-8.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-8.1 */ class TimetableScenarioPublisherTest extends TestCase { use RegisterSchemaValidation; diff --git a/tests/unit/Settings/PlanninqRegisterSchemaTest.php b/tests/unit/Settings/PlanninqRegisterSchemaTest.php index 1e8d503e..0f4d6b5c 100644 --- a/tests/unit/Settings/PlanninqRegisterSchemaTest.php +++ b/tests/unit/Settings/PlanninqRegisterSchemaTest.php @@ -632,7 +632,7 @@ public function testDueSoonRecipientFieldExistsOnSchema(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.1 */ public function testRegisterDeclaresExactlySeventeenSchemas(): void { $expected = ['task', 'project', 'projectPhase', 'column', 'plannedTimeEntry', 'label', 'dependency', 'timetableSession', 'projectLogEntry', 'risk', 'projectStatusReport', 'projectPortfolio', 'financeLine', 'projectField', 'projectRelease', 'timetableWish', 'timetableScenario']; @@ -1279,7 +1279,7 @@ public function testTaskReleaseReferenceIsNullable(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.1 */ public function testTimetableWishSchemaAndItsRules(): void { $schema = $this->register['components']['schemas']['timetableWish']; @@ -1316,7 +1316,7 @@ public function testTimetableWishSchemaAndItsRules(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.1 */ public function testTimetableScenarioKeepsItsInputAndResult(): void { $schema = $this->register['components']['schemas']['timetableScenario']; @@ -1355,7 +1355,7 @@ public function testTimetableScenarioKeepsItsInputAndResult(): void { * * @return void * - * @spec openspec/changes/timetabling-generator/tasks.md#task-1.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-1.1 */ public function testMockRegisterCarriesTimetableGeneratorDemoRows(): void { $mock = json_decode((string)file_get_contents(__DIR__ . '/../../../lib/Settings/planninq_mock_register.json'), true, 512, JSON_THROW_ON_ERROR); diff --git a/tests/unit/Timetabling/LocalSearchSolverTest.php b/tests/unit/Timetabling/LocalSearchSolverTest.php index 667088fe..c57dc948 100644 --- a/tests/unit/Timetabling/LocalSearchSolverTest.php +++ b/tests/unit/Timetabling/LocalSearchSolverTest.php @@ -38,7 +38,7 @@ /** * The solver on small hand-made weeks and one generated school week. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.1 */ class LocalSearchSolverTest extends TestCase { use RegisterSchemaValidation; diff --git a/tests/unit/Timetabling/TimetableScorerTest.php b/tests/unit/Timetabling/TimetableScorerTest.php index d63d5a9f..e3fb8f5f 100644 --- a/tests/unit/Timetabling/TimetableScorerTest.php +++ b/tests/unit/Timetabling/TimetableScorerTest.php @@ -33,7 +33,7 @@ /** * Hand-made placements on a two-day, four-period week. * - * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-4.1 */ class TimetableScorerTest extends TestCase { use RegisterSchemaValidation; diff --git a/tests/vitest/scenarioCompare.spec.js b/tests/vitest/scenarioCompare.spec.js index 18330d0e..42beabaf 100644 --- a/tests/vitest/scenarioCompare.spec.js +++ b/tests/vitest/scenarioCompare.spec.js @@ -1,7 +1,7 @@ /** * Vitest tests for comparing timetable scenarios (timetabling-generator, section 7). * - * @spec openspec/changes/timetabling-generator/tasks.md#task-7.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-7.1 */ import { describe, expect, it } from 'vitest' import { bestPositions, compareRows, lessonDiff } from '../../src/utils/scenarioCompare.js' diff --git a/tests/vitest/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js index 4c172041..5d1785dc 100644 --- a/tests/vitest/timetableScenarios.spec.js +++ b/tests/vitest/timetableScenarios.spec.js @@ -1,7 +1,7 @@ /** * Vitest tests for the timetable scenario page helpers (timetabling-generator, section 5.3). * - * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-5.3 */ import { describe, expect, it } from 'vitest' import { brokenHardRows, brokenSoftRows, canPublishDrafts, canTakeCurrentTimetable, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' diff --git a/tests/vitest/timetableWishes.spec.js b/tests/vitest/timetableWishes.spec.js index 764c23d5..2b928b63 100644 --- a/tests/vitest/timetableWishes.spec.js +++ b/tests/vitest/timetableWishes.spec.js @@ -1,7 +1,7 @@ /** * Vitest tests for the timetable wish editor helpers (timetabling-generator, section 3). * - * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @spec openspec/changes/archive/2026-09-30-timetabling-generator/tasks.md#task-3.1 */ import Ajv from 'ajv' import { readFileSync } from 'node:fs'