diff --git a/appinfo/routes.php b/appinfo/routes.php index 850e9e71..99bd88eb 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,4 +53,8 @@ ['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'], ]); diff --git a/l10n/be.js b/l10n/be.js index 8986f7bd..d5e5d888 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1008,7 +1008,44 @@ 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": "Сцэнарый" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 66948d86..56c7e1f4 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1007,7 +1007,44 @@ "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": "Сцэнарый" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 81136283..4ce5251f 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1008,7 +1008,44 @@ 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": "Сценарий" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index e73ba4d1..f9f62d9f 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1007,7 +1007,44 @@ "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": "Сценарий" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index e5d2ea1b..381850eb 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index d9872360..704e09e8 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index d68871b0..2f166e6c 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 0c8a732d..b191ed4f 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index dc0c2e0b..1320cd86 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1008,7 +1008,44 @@ 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ář" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 69c1006c..f312dcd5 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1007,7 +1007,44 @@ "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ář" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 3bbeff32..a039c2c5 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 0a6f81f2..0c9298c1 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index aefb9ccd..dd58905e 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index eef9e8af..27544709 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index a813d4d1..2305ed50 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1008,7 +1008,44 @@ 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": "Σενάριο" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 6dd0518e..ccb40592 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1007,7 +1007,44 @@ "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": "Σενάριο" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 6fe2c3ab..3679e9d5 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -986,7 +986,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index f551fa56..c976c906 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -985,7 +985,44 @@ "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" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index d809f625..6941b4cf 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index b2aa9898..ca56596b 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index cf714176..166bce92 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 132f4156..3024d80a 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 007249c5..1cffd9d1 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 888d9764..213ff06d 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 73238b14..8fe820bc 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 1f7b947f..2ac95cc1 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 8f460dfb..09b45a11 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index d0a50576..b54ad526 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 22c6c27e..a22d7d9b 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index 62a8b373..803d1680 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 418cb9f0..34b24492 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 702e783e..4eb3612f 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 0bf5e511..45bb1253 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 14315c2b..156f3c8d 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index 28438919..ea59d56c 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 4d83da62..440420d3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 056e0794..393d319b 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 693cb28d..c3ea58de 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 167a2c82..ad25e2dd 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index a9f1daad..3035ffcb 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index a049989b..4675c34d 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index e253cd7b..74783f55 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 8cec77f8..8f1e6ec4 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1008,7 +1008,44 @@ 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": "Сценарио" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 9575a935..7d3259db 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1007,7 +1007,44 @@ "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": "Сценарио" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index ea5a2717..10b83ab6 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 59ef6162..52573b2d 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index 6a42a851..e589b7f7 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 41ee8836..09528a88 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 0b0655df..653aaa36 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1021,7 +1021,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index e9849877..00faa044 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1020,7 +1020,44 @@ "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" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 757cb92b..75f9e257 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index e6099d88..f7e08281 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index fc500900..cb8ac8bf 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 1d2ff8bc..03a75d71 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 891db5ad..554a534b 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 92522b85..9ff41bf9 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 750b55dc..b6631d2e 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index a7e47901..40513414 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index b81511ea..53c172bf 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1008,7 +1008,44 @@ 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": "Сценарий" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index eb715f66..7b3c2609 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1007,7 +1007,44 @@ "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": "Сценарий" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index ccd9de81..8a9a73bc 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index c1e92be7..0ee3c056 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index 39b463b8..31409b3c 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index ad8b8f79..cf354b1a 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 3c43b018..690e2a89 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bf1d2e14..20e293f5 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index befb312d..58890fe1 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1008,7 +1008,44 @@ 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": "Сценарио" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 4724cf92..6576f470 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1007,7 +1007,44 @@ "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": "Сценарио" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index e62e5a2a..95b6971a 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index adfdc18d..42d4a236 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 471d8f14..e7fb2cd1 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1008,7 +1008,44 @@ 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" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index ebdca64a..0f3382b1 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1007,7 +1007,44 @@ "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" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index 7f30c879..ce906efb 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1008,7 +1008,44 @@ 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": "Сценарій" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 91f24b07..99922297 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1007,7 +1007,44 @@ "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": "Сценарій" }, "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..90ec8748 --- /dev/null +++ b/lib/Controller/TimetableScenarioController.php @@ -0,0 +1,91 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Controller; + +use InvalidArgumentException; +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableGenerationService; +use OCA\Planninq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; + +/** + * The generate endpoint of timetable scenarios. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param TimetableGenerationService $generation Queues the run. + * @param IJobList $jobList Runs it in the background. + * @param SettingsService $settingsService Tells whether the user is an admin. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly TimetableGenerationService $generation, + private readonly IJobList $jobList, + private readonly SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Queue a generator run for a scenario; the page follows its progress on the stored scenario. + * + * @param string $id The scenario id. + * + * @return JSONResponse + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function generate(string $id): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can generate a timetable.'], Http::STATUS_FORBIDDEN); + } + + try { + $scenario = $this->generation->queue(id: $id); + } catch (InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + $this->jobList->add(GenerateTimetableScenario::class, ['scenario' => $id]); + + return new JSONResponse(['id' => $id, 'status' => $scenario['status'], 'lessons' => count($scenario['input']['lessons'])], Http::STATUS_ACCEPTED); + }//end generate() +}//end class diff --git a/lib/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/TimetableInputBuilder.php b/lib/Service/TimetableInputBuilder.php new file mode 100644 index 00000000..02c6d4a2 --- /dev/null +++ b/lib/Service/TimetableInputBuilder.php @@ -0,0 +1,203 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Timetabling\SolverInput; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; + +/** + * Builds the SolverInput of a generator run. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableInputBuilder { + + /** + * App config key holding the uploaded rooms sheet, parsed. + * + * @var string + */ + public const CSV_ROOMS_KEY = 'timetable_csv_rooms'; + + /** + * App config key holding the uploaded activities sheet, parsed. + * + * @var string + */ + public const CSV_ACTIVITIES_KEY = 'timetable_csv_activities'; + + /** + * Why an input is empty when nothing supplied activities. + * + * @var string + */ + public const NO_ACTIVITIES = 'No activities: learniq did not answer and no CSV was uploaded'; + + /** + * The wish fields the solver reads. + * + * @var array + */ + private const WISH_FIELDS = ['appliesTo', 'reference', 'kind', 'periods', 'limit', 'strength', 'weight']; + + /** + * Constructor. + * + * @param IEventDispatcher $dispatcher Dispatches the activities query. + * @param IAppConfig $appConfig Holds the uploaded sheets. + * @param TimetableGridService $grid The week grid. + * + * @return void + */ + public function __construct( + private readonly IEventDispatcher $dispatcher, + private readonly IAppConfig $appConfig, + private readonly TimetableGridService $grid, + ) { + }//end __construct() + + /** + * The input of a run for one academic year and the given wishes. + * + * @param string $academicYear The academic year, such as `2026-2027`. + * @param array> $wishes timetableWish objects. + * + * @return SolverInput + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function build(string $academicYear, array $wishes): SolverInput { + $periods = $this->grid->periodKeys(); + $wishes = array_map(fn (array $wish): array => $this->wish(wish: $wish), array_values($wishes)); + + $event = new TimetableActivitiesQueryEvent(academicYear: $academicYear); + $this->dispatcher->dispatchTyped($event); + + $activities = ($event->getActivities() ?? []); + $rooms = $event->getRooms(); + $source = (string)$event->getAnsweredBy(); + if ($activities === []) { + $activities = $this->stored(key: self::CSV_ACTIVITIES_KEY); + $rooms = $this->stored(key: self::CSV_ROOMS_KEY); + $source = 'csv'; + } + + if ($activities === []) { + return new SolverInput(periods: $periods, rooms: [], lessons: [], wishes: $wishes, source: 'none', reason: self::NO_ACTIVITIES); + } + + $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 + */ + private function wish(array $wish): array { + $out = ['id' => (string)($wish['id'] ?? ($wish['@self']['id'] ?? ''))]; + foreach (self::WISH_FIELDS as $field) { + if (array_key_exists($field, $wish) === true) { + $out[$field] = $wish[$field]; + } + } + + return $out; + }//end wish() + + /** + * A stored sheet, or an empty list. + * + * @param string $key The app config key. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + private function stored(string $key): array { + $rows = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '[]', lazy: true), true); + if (is_array($rows) === false) { + return []; + } + + return array_values(array_filter($rows, 'is_array')); + }//end stored() +}//end class diff --git a/lib/Service/TimetableScenarioStore.php b/lib/Service/TimetableScenarioStore.php new file mode 100644 index 00000000..260587f1 --- /dev/null +++ b/lib/Service/TimetableScenarioStore.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Service; + +use Psr\Container\ContainerInterface; +use RuntimeException; +use Throwable; + +/** + * The generator's access to its scenarios and wishes. + * + * Written with RBAC off: the store is reached only from the admin-only + * generate endpoint and from the queued job, which runs without a user. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioStore { + + /** + * The register slug. + */ + public const REGISTER = 'planninq'; + + /** + * The scenario schema slug. + */ + public const SCENARIO = 'timetableScenario'; + + /** + * The wish schema slug. + */ + public const WISH = 'timetableWish'; + + /** + * The most wishes one run reads. + */ + private const MAX_WISHES = 1000; + + /** + * OpenRegister ObjectService FQCN. + */ + private const OR_OBJECT_SERVICE = 'OCA\\OpenRegister\\Service\\ObjectService'; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister's ObjectService by name. + * + * @return void + */ + public function __construct( + private readonly ContainerInterface $container, + ) { + }//end __construct() + + /** + * One scenario's data, or null when it does not exist. + * + * @param string $id The scenario id. + * + * @return array|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function load(string $id): ?array { + try { + $object = $this->objectService()->find(id: $id, register: self::REGISTER, schema: self::SCENARIO, _rbac: false); + } catch (Throwable) { + return null; + } + + return $this->dataOf(object: $object); + }//end load() + + /** + * Store a scenario's data under its id. + * + * @param string $id The scenario id. + * @param array $data The complete scenario. + * + * @return void + * + * @throws RuntimeException When OpenRegister refuses it. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function save(string $id, array $data): void { + unset($data['@self'], $data['id']); + $saved = $this->objectService()->saveObject(object: $data, register: self::REGISTER, schema: self::SCENARIO, uuid: $id, _rbac: false); + if ($saved === null) { + throw new RuntimeException('OpenRegister did not store the scenario.'); + } + }//end save() + + /** + * Every wish. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ + public function wishes(): array { + $results = $this->objectService()->searchObjectsBySlug( + registerSlug: self::REGISTER, + schemaSlug: self::WISH, + filters: ['_limit' => self::MAX_WISHES], + _rbac: false + ); + $wishes = []; + foreach ((new TimetableSessionRows())->listOf(results: $results) as $row) { + $data = $this->dataOf(object: $row); + if ($data !== null) { + $wishes[] = $data; + } + } + + return $wishes; + }//end wishes() + + /** + * An object's data with its id, from an ObjectEntity or an array; null for anything else. + * + * @param mixed $object The object. + * + * @return array|null + */ + private function dataOf(mixed $object): ?array { + if (is_object($object) === false && is_array($object) === false) { + return null; + } + + $rows = new TimetableSessionRows(); + $data = $rows->dataOf(row: $object); + $id = $rows->idOf(row: $object); + if ($id !== '') { + $data['id'] = $id; + } + + return $data; + }//end dataOf() + + /** + * OpenRegister's ObjectService, cleared of any earlier caller's scope. + * + * @return object + * + * @throws RuntimeException When OpenRegister is not available. + */ + private function objectService(): object { + try { + $objectService = $this->container->get(self::OR_OBJECT_SERVICE); + } catch (Throwable $e) { + throw new RuntimeException('OpenRegister is not available.', 0, $e); + } + + if (method_exists($objectService, 'clearCurrents') === true) { + $objectService->clearCurrents(); + } + + return $objectService; + }//end objectService() +}//end class diff --git a/lib/Timetabling/LocalSearchSolver.php b/lib/Timetabling/LocalSearchSolver.php new file mode 100644 index 00000000..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..14aa94b5 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -10,24 +10,24 @@ 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 diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 31c287ca..f9fc2c54 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -9012,10 +9012,11 @@ "timeedit": "docs read 2026-09-27: https://www.academy.timeedit.com/products/autopilot 'Employ the most sophisticated TimeEdit scheduling concepts as either hard or soft constraints in your auto-scheduling process' ; https://www.academy.timeedit.com/product-updates/119928309 'exact start time and weekday(s) when importing activities, letting you use these when scheduling as soft or hard constraints'" }, "built": { - "state": "specified", - "evidence": "Nothing in planninq or learniq generates a timetable: planninq stores lessons delivered by a rostering system (lib/Service/TimetableSessionService.php upsert, drafts and publish) and places none. Ruben decided on 29 Sep 2026 to build a generator in planninq (build-all DECISIONS.md row 17), reversing the 28 Sep decided-no and the learniq non-goal; learniq decided no in ConductionNL/learniq#1493 before that decision.", + "state": "built", + "evidence": "Wishes: timetableWish schema (appliesTo, reference, kind, periods, limit, strength hard or soft, weight 1 to 3) edited on the TimetableWishes index page with src/dialogs/TimetableWishDialog.vue (period picker on the week grid). Generator: lib/Timetabling/LocalSearchSolver.php behind TimetableSolver (greedy most-constrained-first, simulated annealing on TimetableScorer, a repair pass so a generated scenario never breaks a hard wish; a lesson it cannot place names the blocking hard wish), run by POST /api/timetable/scenarios/{id}/generate and the GenerateTimetableScenario queued job in 60-second steps up to the time budget; TimetableScenarioDetail page shows unplaced lessons and broken soft wishes. Tests: LocalSearchSolverTest (feasible week keeps the hard wish, impossible hard wish names itself, hard limit never broken, free soft wish kept, same seed same result, 600 lessons inside 60 seconds validated against the real timetableScenario fragment), TimetableGenerationServiceTest, TimetableScenarioControllerTest, vitest timetableWishes and timetableScenarios.", "owner": "ConductionNL/planninq", - "change": "openspec/changes/timetabling-generator" + "change": "openspec/changes/timetabling-generator", + "readOn": "2026-09-30" }, "provider": "learniq", "providerHow": "read-from-code", diff --git a/src/components/TimetableScenarioSections.vue b/src/components/TimetableScenarioSections.vue new file mode 100644 index 00000000..fa0c1b91 --- /dev/null +++ b/src/components/TimetableScenarioSections.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/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..dbaef61d 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.", "route": "/timetable/scenarios", "type": "index", "title": "Timetable scenarios", "config": { + "register": "planninq", + "schema": "timetableScenario", + "columns": ["title", "source", "status", "windowFrom", "windowTo"], + "defaultSort": [{ "field": "windowFrom", "order": "desc" }] + } }, + { "id": "TimetableScenarioDetail", "_note": "One timetable scenario: its fields through the declarative detail page, and in its sections slot the generator run (status, progress, Generate), the lessons without a place with the hard wish that blocked them, and the broken soft wishes.", "route": "/timetable/scenarios/:id", "type": "detail", "title": "Scenario", "config": { + "register": "planninq", + "schema": "timetableScenario" + }, "slots": { "sections": "TimetableScenarioSections" } }, { "id": "Boards", "_note": "Board picker: the projects a user can open a board for, as entry points rather than records. The cards route to /projects/:id and expose no record actions.", "route": "/boards", "type": "custom", "title": "Boards", "component": "Boards", "config": {} }, { "id": "PortfolioStatus", "_note": "One portfolio's projects with their lifecycle status, progress, dates, budget and six aspect statuses, under a roll-up per aspect with the worst state and a No report count. It combines the projects, their tasks and an admin setting into one table and a roll-up, which no index or dashboard widget expresses.", "route": "/portfolio/status", "type": "custom", "title": "Portfolio status", "component": "PortfolioStatus", "config": {} }, { "id": "PortfolioFinance", "_note": "Money across one portfolio: a row per project with budget, commitments, actual cost including labour from booked time, forecast and what remains, and the totals, for the projects whose money the viewer may see. It combines finance lines, time entries and projects into computed rows and a cross-checked total, which no index or dashboard widget expresses.", "route": "/portfolio/finance", "type": "custom", "title": "Portfolio finance", "component": "PortfolioFinance", "config": {} }, diff --git a/src/registry.js b/src/registry.js index d33fefe8..400349b1 100644 --- a/src/registry.js +++ b/src/registry.js @@ -20,6 +20,8 @@ // dashboard widget TYPE resolves against the library's own widget catalog via // registerDashboardWidget() in main.js; this registry is for page components // and slot overrides only. +import TimetableScenarioSections from './components/TimetableScenarioSections.vue' +import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' import Boards from './views/Boards.vue' import MyWork from './views/MyWork.vue' import Portfolio from './views/Portfolio.vue' @@ -49,6 +51,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 +80,6 @@ export default { ProjectTimeline: page(ProjectTimeline), TaskDetail: page(TaskDetail), Timesheet: page(Timesheet), + TimetableScenarioSections: slot(TimetableScenarioSections), + TimetableWishDialog: slot(TimetableWishDialog), } diff --git a/src/utils/timetableScenarios.js b/src/utils/timetableScenarios.js new file mode 100644 index 00000000..1f937326 --- /dev/null +++ b/src/utils/timetableScenarios.js @@ -0,0 +1,86 @@ +/** + * Helpers for the timetable scenario page (timetabling-generator, section 5.3). + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +/** + * Whether a run is still going, so the page keeps reading the scenario. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {boolean} True while queued or running. + */ +export function isRunning(scenario) { + return ['queued', 'running'].includes(scenario?.status) +} + +/** + * The share of the time budget spent, from 0 to 100. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {number} The percentage, whole. + */ +export function progressPercent(scenario) { + if (scenario?.status === 'done' || scenario?.status === 'published') { + return 100 + } + const budget = Number(scenario?.metrics?.budgetSeconds ?? 0) + const spent = Number(scenario?.metrics?.secondsSpent ?? 0) + if (budget <= 0) { + return 0 + } + return Math.min(100, Math.round((spent / budget) * 100)) +} + +/** + * A short description of a wish, from the scenario's own input snapshot. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} wish The wish as the input keeps it. + * @return {{appliesTo: string, reference: string, kind: string, periods: string[]}|null} The parts, or null when unknown. + */ +export function wishSummary(wish) { + if (!wish) { + return null + } + return { + appliesTo: wish.appliesTo ?? '', + reference: wish.reference ?? '', + kind: wish.kind ?? '', + periods: Array.isArray(wish.periods) ? wish.periods : [], + } +} + +/** + * The unplaced lessons with the hard wish that blocked each. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {Array<{lesson: string, wish: object|null, reason: string}>} One row per lesson. + */ +export function unplacedRows(scenario) { + const wishes = new Map((scenario?.input?.wishes ?? []).map((wish) => [String(wish.id), wish])) + return (scenario?.unplaced ?? []).map((row) => ({ + lesson: row.lesson, + wish: wishSummary(row.wish ? wishes.get(String(row.wish)) ?? { reference: String(row.wish) } : null), + reason: row.reason ?? (row.wish ? 'hardWish' : 'noFreePlace'), + })) +} + +/** + * The broken soft wishes, heaviest first, with the lessons that break them. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + * @param {object|null} scenario The scenario. + * @return {Array<{wish: object|null, weight: number, lessons: string[]}>} One row per wish. + */ +export function brokenSoftRows(scenario) { + const wishes = new Map((scenario?.input?.wishes ?? []).map((wish) => [String(wish.id), wish])) + return (scenario?.brokenWishes ?? []) + .filter((row) => row.strength !== 'hard' && row.weight !== null) + .map((row) => ({ wish: wishSummary(wishes.get(String(row.wish)) ?? { reference: String(row.wish) }), weight: Number(row.weight ?? 1), lessons: row.lessons ?? [] })) + .sort((a, b) => (b.weight * b.lessons.length) - (a.weight * a.lessons.length)) +} diff --git a/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..0db8186c --- /dev/null +++ b/tests/e2e/timetable-generator.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * E2E coverage for a generator run (timetabling-generator, section 5). + * + * @e2e timetable-generator::generate-a-scenario-that-respects-a-hard-wish + * + * The run itself is a queued job, so the test starts it through the endpoint, + * runs the background jobs with `occ background-job:worker` when the harness + * allows it (NC_OCC), and reads the stored scenario. The page is checked for the + * status and the two lists. + */ + +import type { APIRequestContext } from '@playwright/test' + +import { expect, request, test } from '@playwright/test' +import { execSync } from 'node:child_process' +import { BASE_URL } from './base-url.ts' +import { PLANNINQ_ROOT } from './nav.ts' + +const RUN = Date.now().toString(36) +const OBJECTS = '/index.php/apps/openregister/api/objects/planninq' +const API = '/index.php/apps/planninq/api' + +async function admin(): Promise { + return request.newContext({ + baseURL: BASE_URL, + httpCredentials: { + username: process.env.NC_ADMIN_USER ?? process.env.ADMIN_USER ?? 'admin', + password: process.env.NC_ADMIN_PASS ?? process.env.ADMIN_PASSWORD ?? 'admin', + send: 'always', + }, + extraHTTPHeaders: { 'Content-Type': 'application/json', 'OCS-APIRequest': 'true', Accept: 'application/json' }, + }) +} + +test.describe('Timetable generator', () => { + let api: APIRequestContext + const created: string[] = [] + + test.beforeAll(async () => { + api = await admin() + }) + + test.afterAll(async () => { + for (const path of created) { + await api.delete(path, { failOnStatusCode: false }) + } + await api.dispose() + }) + + test('generate a scenario that respects a hard wish', async ({ page }) => { + const upload = await api.post(`${API}/timetable/input/upload`, { + data: { + rooms: 'reference,capacity,type\nB12,30,classroom\nB13,30,classroom', + activities: `group,subject,teacher,lessons per week,lesson length,room type\n3A,English,klaas-${RUN},3,1,classroom\n3B,Maths,noor-${RUN},2,1,classroom`, + }, + }) + expect(upload.ok(), 'upload the sheets').toBe(true) + + const wish = await api.post(`${OBJECTS}/timetableWish`, { + data: { appliesTo: 'teacher', reference: `klaas-${RUN}`, kind: 'unavailable', periods: ['wed-5', 'wed-6', 'wed-7', 'wed-8'], strength: 'hard' }, + }) + const wishBody = await wish.json() + created.push(`${OBJECTS}/timetableWish/${wishBody.id ?? wishBody['@self']?.id}`) + + const scenario = await api.post(`${OBJECTS}/timetableScenario`, { + data: { title: `Try ${RUN}`, source: 'generated', weekOf: '2026-10-05', windowFrom: '2026-10-05', windowTo: '2026-10-30', status: 'queued' }, + }) + const scenarioBody = await scenario.json() + const id = scenarioBody.id ?? scenarioBody['@self']?.id + created.push(`${OBJECTS}/timetableScenario/${id}`) + + const generate = await api.post(`${API}/timetable/scenarios/${id}/generate`) + expect(generate.status()).toBe(202) + + if (process.env.NC_OCC) { + execSync(`${process.env.NC_OCC} background-job:worker -t 120 'OCA\\Planninq\\BackgroundJob\\GenerateTimetableScenario'`, { stdio: 'ignore' }) + const stored = await (await api.get(`${OBJECTS}/timetableScenario/${id}`)).json() + expect(['running', 'done']).toContain(stored.status) + expect(stored.metrics.hardWishesBroken).toBe(0) + for (const row of stored.placements.filter((p: { lesson: string }) => p.lesson.startsWith('3A:English'))) { + expect(['wed-5', 'wed-6', 'wed-7', 'wed-8']).not.toContain(row.period) + } + } + + await page.goto(`${PLANNINQ_ROOT}timetable/scenarios/${id}`) + await expect(page.getByTestId('scenario-sections')).toBeVisible() + await expect(page.getByTestId('scenario-status')).not.toBeEmpty() + }) +}) diff --git a/tests/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..d86bc75d --- /dev/null +++ b/tests/unit/Controller/TimetableScenarioControllerTest.php @@ -0,0 +1,110 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Controller; + +use InvalidArgumentException; +use OCA\Planninq\BackgroundJob\GenerateTimetableScenario; +use OCA\Planninq\Controller\TimetableScenarioController; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableGenerationService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\BackgroundJob\IJobList; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * The generate endpoint. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.2 + */ +class TimetableScenarioControllerTest extends TestCase { + + /** + * A controller for the given caller, with the given generation service and job list. + * + * @param bool $admin Whether the caller is an admin. + * @param TimetableGenerationService $generation The generation service. + * @param IJobList $jobList The job list. + * + * @return TimetableScenarioController + */ + private function controller(bool $admin, TimetableGenerationService $generation, IJobList $jobList): TimetableScenarioController { + $settings = $this->createMock(SettingsService::class); + $settings->method('isCurrentUserAdmin')->willReturn($admin); + return new TimetableScenarioController(request: $this->createMock(IRequest::class), generation: $generation, jobList: $jobList, settingsService: $settings); + }//end controller() + + /** + * A non-admin is refused and nothing is queued. + * + * @return void + */ + public function testANonAdminIsRefused(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->expects($this->never())->method('queue'); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->never())->method('add'); + + $response = $this->controller(admin: false, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertNotEmpty((new ReflectionMethod(TimetableScenarioController::class, 'generate'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminIsRefused() + + /** + * An admin's run is queued and answered with 202. + * + * @return void + */ + public function testAnAdminQueuesARun(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->method('queue')->willReturn(['status' => 'queued', 'input' => ['lessons' => [['key' => 'a'], ['key' => 'b']]]]); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->once())->method('add')->with(GenerateTimetableScenario::class, ['scenario' => 's-1']); + + $response = $this->controller(admin: true, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_ACCEPTED, actual: $response->getStatus()); + self::assertSame(expected: ['id' => 's-1', 'status' => 'queued', 'lessons' => 2], actual: $response->getData()); + }//end testAnAdminQueuesARun() + + /** + * A scenario the service refuses answers 400 with the reason and queues nothing. + * + * @return void + */ + public function testARefusedScenarioAnswersBadRequest(): void { + $generation = $this->createMock(TimetableGenerationService::class); + $generation->method('queue')->willThrowException(new InvalidArgumentException('No activities: learniq did not answer and no CSV was uploaded')); + $jobList = $this->createMock(IJobList::class); + $jobList->expects($this->never())->method('add'); + + $response = $this->controller(admin: true, generation: $generation, jobList: $jobList)->generate(id: 's-1'); + + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); + self::assertStringContainsString(needle: 'No activities', haystack: $response->getData()['error']); + }//end testARefusedScenarioAnswersBadRequest() +}//end class diff --git a/tests/unit/Service/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/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/timetableScenarios.spec.js b/tests/vitest/timetableScenarios.spec.js new file mode 100644 index 00000000..71bcd8ea --- /dev/null +++ b/tests/vitest/timetableScenarios.spec.js @@ -0,0 +1,61 @@ +/** + * Vitest tests for the timetable scenario page helpers (timetabling-generator, section 5.3). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-5.3 + */ +import { describe, expect, it } from 'vitest' +import { brokenSoftRows, isRunning, progressPercent, unplacedRows } from '../../src/utils/timetableScenarios.js' + +const scenario = { + status: 'running', + metrics: { secondsSpent: 150, budgetSeconds: 600 }, + input: { + wishes: [ + { id: 'w-klaas', appliesTo: 'teacher', reference: 'klaas', kind: 'unavailable', periods: ['wed-5', 'wed-6'], strength: 'hard' }, + { id: 'w-noor', appliesTo: 'teacher', reference: 'noor', kind: 'avoid', periods: ['fri-8'], strength: 'soft', weight: 1 }, + { id: 'w-3a', appliesTo: 'group', reference: '3A', kind: 'noGaps', strength: 'soft', weight: 3 }, + ], + }, + unplaced: [ + { lesson: '3A:English:3', wish: 'w-klaas', reason: 'hardWishPeriods' }, + { lesson: '3B:Art:1', wish: null, reason: 'noFreePlace' }, + ], + brokenWishes: [ + { wish: 'w-noor', strength: 'soft', weight: 1, lessons: ['3B:Maths:1', '3B:Maths:2'] }, + { wish: 'w-3a', strength: 'soft', weight: 3, lessons: ['3A:English:1'] }, + ], +} + +describe('the run', () => { + it('is going while queued or running', () => { + expect(isRunning(scenario)).toBe(true) + expect(isRunning({ status: 'queued' })).toBe(true) + expect(isRunning({ status: 'done' })).toBe(false) + expect(isRunning(null)).toBe(false) + }) + + it('reports the share of the time budget spent', () => { + expect(progressPercent(scenario)).toBe(25) + expect(progressPercent({ status: 'running', metrics: { secondsSpent: 900, budgetSeconds: 600 } })).toBe(100) + expect(progressPercent({ status: 'done', metrics: {} })).toBe(100) + expect(progressPercent({ status: 'queued' })).toBe(0) + }) +}) + +describe('the lists (scenario: a lesson that cannot be placed is listed with its reason)', () => { + it('names the hard wish that blocked each unplaced lesson', () => { + expect(unplacedRows(scenario)).toEqual([ + { lesson: '3A:English:3', wish: { appliesTo: 'teacher', reference: 'klaas', kind: 'unavailable', periods: ['wed-5', 'wed-6'] }, reason: 'hardWishPeriods' }, + { lesson: '3B:Art:1', wish: null, reason: 'noFreePlace' }, + ]) + }) + + it('lists broken soft wishes heaviest first with their weight', () => { + const rows = brokenSoftRows(scenario) + expect(rows.map((row) => [row.wish.reference, row.weight, row.lessons.length])).toEqual([['3A', 3, 1], ['noor', 1, 2]]) + }) + + it('keeps a wish that is no longer in the input readable by its id', () => { + expect(unplacedRows({ unplaced: [{ lesson: 'x', wish: 'gone' }] })[0].wish.reference).toBe('gone') + }) +}) 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) + }) +})