diff --git a/appinfo/routes.php b/appinfo/routes.php index 850e9e71..3f61b44a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,4 +53,10 @@ ['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'], + // 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 8986f7bd..fa681aa9 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1008,7 +1008,67 @@ 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": "Пажаданні да раскладу", + "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": "Сцэнарый", + "Take the current timetable": "Узяць бягучы расклад", + "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", + "Broken hard wishes": "Парушаныя абавязковыя пажаданні", + "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 66948d86..d0ae7db8 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1007,7 +1007,67 @@ "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": "Пажаданні да раскладу", + "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": "Сцэнарый", + "Take the current timetable": "Узяць бягучы расклад", + "Could not take the current timetable.": "Не ўдалося ўзяць бягучы расклад.", + "Broken hard wishes": "Парушаныя абавязковыя пажаданні", + "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 81136283..9f28e486 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1008,7 +1008,67 @@ 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": "Желания за разписанието", + "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": "Сценарий", + "Take the current timetable": "Вземи текущото разписание", + "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", + "Broken hard wishes": "Нарушени задължителни желания", + "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 e73ba4d1..7cf9b591 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1007,7 +1007,67 @@ "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": "Желания за разписанието", + "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": "Сценарий", + "Take the current timetable": "Вземи текущото разписание", + "Could not take the current timetable.": "Текущото разписание не можа да бъде взето.", + "Broken hard wishes": "Нарушени задължителни желания", + "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 e5d2ea1b..73660e13 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 d9872360..3ee35c20 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 d68871b0..85c3cfe5 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 0c8a732d..0180ea79 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 dc0c2e0b..5a777b93 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1008,7 +1008,67 @@ 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", + "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ář", + "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", + "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 69c1006c..1cc94da3 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1007,7 +1007,67 @@ "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", + "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ář", + "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", + "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 3bbeff32..37982ac6 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 0a6f81f2..0401080c 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 aefb9ccd..d3c85ed9 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 eef9e8af..8e3f37e0 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 a813d4d1..1a93ce73 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1008,7 +1008,67 @@ 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": "Επιθυμίες ωρολογίου προγράμματος", + "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": "Σενάριο", + "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", + "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", + "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", + "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 6dd0518e..fa28e650 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1007,7 +1007,67 @@ "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": "Επιθυμίες ωρολογίου προγράμματος", + "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": "Σενάριο", + "Take the current timetable": "Λήψη του τρέχοντος ωρολογίου προγράμματος", + "Could not take the current timetable.": "Δεν ήταν δυνατή η λήψη του τρέχοντος ωρολογίου προγράμματος.", + "Broken hard wishes": "Υποχρεωτικές επιθυμίες που παραβιάστηκαν", + "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 6fe2c3ab..15ea226c 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -986,7 +986,67 @@ 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", + "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", + "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", + "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 f551fa56..45bedc90 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -985,7 +985,67 @@ "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", + "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", + "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", + "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 d809f625..724e58db 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 b2aa9898..fb3e0448 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 cf714176..25de6092 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 132f4156..84494ba7 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 007249c5..4a29ada9 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 888d9764..158bd307 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 73238b14..363d1eca 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 1f7b947f..24953b3a 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 8f460dfb..d27dd8b7 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 d0a50576..63dceac8 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 22c6c27e..5e76baab 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 62a8b373..c6e09467 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 418cb9f0..ddf057d2 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 702e783e..49bc9e2a 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 0bf5e511..16945e2e 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1008,7 +1008,67 @@ 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á", + "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", + "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", + "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 14315c2b..aff481e9 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1007,7 +1007,67 @@ "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á", + "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", + "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", + "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 28438919..db06f91a 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 4d83da62..5da6b277 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 056e0794..01a195ff 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 693cb28d..50221dd2 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 167a2c82..0906223c 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 a9f1daad..eb822b2c 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 a049989b..2ab47307 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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ā", + "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 e253cd7b..69441905 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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ā", + "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 8cec77f8..925ca846 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1008,7 +1008,67 @@ 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": "Желби за распоредот", + "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": "Сценарио", + "Take the current timetable": "Преземи го тековниот распоред", + "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", + "Broken hard wishes": "Прекршени задолжителни желби", + "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 9575a935..eeb7d16b 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1007,7 +1007,67 @@ "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": "Желби за распоредот", + "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": "Сценарио", + "Take the current timetable": "Преземи го тековниот распоред", + "Could not take the current timetable.": "Тековниот распоред не можеше да се преземе.", + "Broken hard wishes": "Прекршени задолжителни желби", + "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 ea5a2717..3ebaffaf 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 59ef6162..0923b884 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 6a42a851..ec424e05 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 41ee8836..952b1532 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 0b0655df..fe670ef9 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1021,7 +1021,67 @@ 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", + "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", + "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", + "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 e9849877..204a9b4c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1020,7 +1020,67 @@ "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", + "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", + "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", + "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 757cb92b..fe6d4503 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 e6099d88..b680fc86 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 fc500900..d4bfef38 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 1d2ff8bc..ae68e82f 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 891db5ad..0ba72f9b 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 92522b85..e180f576 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 750b55dc..34294665 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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ă", + "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 a7e47901..c22ef28d 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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ă", + "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 b81511ea..31be706c 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1008,7 +1008,67 @@ 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": "Пожелания к расписанию", + "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": "Сценарий", + "Take the current timetable": "Взять текущее расписание", + "Could not take the current timetable.": "Не удалось взять текущее расписание.", + "Broken hard wishes": "Нарушенные обязательные пожелания", + "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 eb715f66..9f45e2c6 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1007,7 +1007,67 @@ "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": "Пожелания к расписанию", + "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": "Сценарий", + "Take the current timetable": "Взять текущее расписание", + "Could not take the current timetable.": "Не удалось взять текущее расписание.", + "Broken hard wishes": "Нарушенные обязательные пожелания", + "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 ccd9de81..f29ba0d0 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 c1e92be7..e188baeb 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 39b463b8..d9334f8f 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 ad8b8f79..e84ddee4 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 3c43b018..7ebb9ea4 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 bf1d2e14..6c81e1fa 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 befb312d..b5606317 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1008,7 +1008,67 @@ 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": "Жеље за распоред", + "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": "Сценарио", + "Take the current timetable": "Преузми тренутни распоред", + "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", + "Broken hard wishes": "Прекршене обавезне жеље", + "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 4724cf92..589b656e 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1007,7 +1007,67 @@ "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": "Жеље за распоред", + "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": "Сценарио", + "Take the current timetable": "Преузми тренутни распоред", + "Could not take the current timetable.": "Тренутни распоред није могуће преузети.", + "Broken hard wishes": "Прекршене обавезне жеље", + "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 e62e5a2a..32700244 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 adfdc18d..5be81a4a 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 471d8f14..d71f455b 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1008,7 +1008,67 @@ 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", + "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", + "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", + "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 ebdca64a..038e1e7a 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1007,7 +1007,67 @@ "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", + "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", + "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", + "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 7f30c879..fea37ccf 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1008,7 +1008,67 @@ 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": "Побажання до розкладу", + "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": "Сценарій", + "Take the current timetable": "Взяти поточний розклад", + "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", + "Broken hard wishes": "Порушені обов'язкові побажання", + "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 91f24b07..81f032e5 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1007,7 +1007,67 @@ "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": "Побажання до розкладу", + "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": "Сценарій", + "Take the current timetable": "Взяти поточний розклад", + "Could not take the current timetable.": "Не вдалося взяти поточний розклад.", + "Broken hard wishes": "Порушені обов'язкові побажання", + "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/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..a3d75afd --- /dev/null +++ b/lib/BackgroundJob/GenerateTimetableScenario.php @@ -0,0 +1,92 @@ + + * @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 + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + 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/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/Controller/TimetableScenarioController.php b/lib/Controller/TimetableScenarioController.php new file mode 100644 index 00000000..7d714f41 --- /dev/null +++ b/lib/Controller/TimetableScenarioController.php @@ -0,0 +1,118 @@ + + * @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\Service\TimetableScenarioImporter; +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 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 { + + /** + * 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. + * @param TimetableScenarioImporter $importer Takes the current timetable into a scenario. + * + * @return void + */ + public function __construct( + IRequest $request, + 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() + + /** + * 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() + /** + * 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/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..0c00df08 --- /dev/null +++ b/lib/Service/TimetableCsvParser.php @@ -0,0 +1,265 @@ + + * @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; + } + + $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() + + /** + * 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/TimetableGenerationService.php b/lib/Service/TimetableGenerationService.php new file mode 100644 index 00000000..132bd6ad --- /dev/null +++ b/lib/Service/TimetableGenerationService.php @@ -0,0 +1,242 @@ + + * @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); + + $status = 'done'; + if ($more === true) { + $status = 'running'; + } + + $this->store->save( + id: $id, + data: array_merge( + $scenario, + $result->toScenario(), + [ + 'status' => $status, + '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); + 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/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 new file mode 100644 index 00000000..8c77b780 --- /dev/null +++ b/lib/Service/TimetableInputBuilder.php @@ -0,0 +1,204 @@ + + * @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->solverWish(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); + } + + $lessons = $this->lessons(activities: $activities); + return new SolverInput(periods: $periods, rooms: array_values($rooms), lessons: $lessons, 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 + * @spec openspec/changes/timetabling-generator/tasks.md#task-6.1 + */ + 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) { + $out[$field] = $wish[$field]; + } + } + + return $out; + }//end solverWish() + + /** + * 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/Service/TimetableScenarioImporter.php b/lib/Service/TimetableScenarioImporter.php new file mode 100644 index 00000000..81d00d22 --- /dev/null +++ b/lib/Service/TimetableScenarioImporter.php @@ -0,0 +1,209 @@ + + * @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 new file mode 100644 index 00000000..100b1740 --- /dev/null +++ b/lib/Service/TimetableScenarioStore.php @@ -0,0 +1,222 @@ + + * @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 lesson schema slug. + */ + public const SESSION = 'timetableSession'; + + /** + * 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() + + /** + * 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. + * + * @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..8436ac91 --- /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 = $this->scorer->costOf(metrics: $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: $this->scorer->costOf(metrics: $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/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/lib/Timetabling/SolverResult.php b/lib/Timetabling/SolverResult.php new file mode 100644 index 00000000..14366dba --- /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/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php new file mode 100644 index 00000000..d19dfb2d --- /dev/null +++ b/lib/Timetabling/TimetableScorer.php @@ -0,0 +1,397 @@ + + * @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 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. + * + * @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/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/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/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 daae39bb..4592e6c5 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -10,32 +10,32 @@ 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 -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] 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/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 31c287ca..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", @@ -9012,10 +9013,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/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/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue new file mode 100644 index 00000000..3e55f6df --- /dev/null +++ b/src/components/TimetableScenarioSections.vue @@ -0,0 +1,407 @@ + + + + + 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..ac993a4d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -55,6 +55,10 @@ { "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": "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 }, { "id": "ReportsMenu", "label": "Reports", "icon": "ChartBoxOutline", "route": "Reports", "section": "footer", "order": 95 }, @@ -238,6 +242,29 @@ { "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": "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" + }, "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 d33fefe8..15125aba 100644 --- a/src/registry.js +++ b/src/registry.js @@ -20,6 +20,9 @@ // 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' import MyWork from './views/MyWork.vue' import Portfolio from './views/Portfolio.vue' @@ -49,6 +52,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 +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/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js new file mode 100644 index 00000000..9a93f66d --- /dev/null +++ b/src/utils/timetableScenarios.js @@ -0,0 +1,112 @@ +/** + * 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)) +} + +/** + * 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/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-generator.spec.ts b/tests/e2e/timetable-generator.spec.ts new file mode 100644 index 00000000..0400c49f --- /dev/null +++ b/tests/e2e/timetable-generator.spec.ts @@ -0,0 +1,119 @@ +/* + * 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 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 + * 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() + }) + 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/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/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/Controller/TimetableScenarioControllerTest.php b/tests/unit/Controller/TimetableScenarioControllerTest.php new file mode 100644 index 00000000..1250ab53 --- /dev/null +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -0,0 +1,160 @@ + + * @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 OCA\Planninq\Service\TimetableScenarioImporter; +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 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 { + + /** + * 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. + * @param TimetableScenarioImporter $importer The importer, a stub when not given. + * + * @return 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, + importer: ($importer ?? $this->createMock(TimetableScenarioImporter::class)) + ); + }//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() + /** + * 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/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/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/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 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/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/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 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([]) + }) +}) diff --git a/tests/vitest/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js new file mode 100644 index 00000000..43ad4aff --- /dev/null +++ b/tests/vitest/timetableScenarios.spec.js @@ -0,0 +1,91 @@ +/** + * 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 { brokenHardRows, brokenSoftRows, canTakeCurrentTimetable, 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') + }) +}) + +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) + }) +}) 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) + }) +})