diff --git a/appinfo/routes.php b/appinfo/routes.php index 850e9e71..7303e607 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,4 +53,6 @@ ['name' => 'timetable#upsert', 'url' => '/api/timetable/sessions/upsert', 'verb' => 'POST'], // Admins publish a source's draft lessons in a window (timetable-draft-review). ['name' => 'timetable#publish', 'url' => '/api/timetable/sessions/publish', 'verb' => 'POST'], + // Admins upload the activities and rooms sheets for the timetable generator (timetabling-generator 2.2). + ['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'], ]); diff --git a/l10n/be.js b/l10n/be.js index 8986f7bd..c7e6af4f 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Прычына", "Why the run failed": "Чаму запуск не ўдаўся", "Published at": "Апублікаваны", - "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі" + "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі", + "Edit wish": "Рэдагаваць пажаданне", + "New wish": "Новае пажаданне", + "Hard: the generator always keeps it": "Абавязковае: генератар заўсёды яго выконвае", + "Soft: the generator keeps it where it can": "Гнуткае: генератар выконвае яго, калі можа", + "Weight (1 to 3)": "Вага (ад 1 да 3)", + "Most lessons a day": "Не больш за столькі ўрокаў у дзень", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Настаўнік (імя карыстальніка)", + "Activity (group:subject)": "Заняткі (група:прадмет)", + "Say who or what the wish is about.": "Пазначце, каго ці чаго датычыць пажаданне.", + "Choose at least one period.": "Выберыце хаця б адзін урок.", + "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", + "Timetable": "Расклад", + "Wishes": "Пажаданні", + "Timetable wishes": "Пажаданні да раскладу" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index 66948d86..21316bd9 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1007,7 +1007,23 @@ "Reason": "Прычына", "Why the run failed": "Чаму запуск не ўдаўся", "Published at": "Апублікаваны", - "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі" + "When the scenario was written out as draft lessons": "Калі сцэнарый запісаны як чарнавыя ўрокі", + "Edit wish": "Рэдагаваць пажаданне", + "New wish": "Новае пажаданне", + "Hard: the generator always keeps it": "Абавязковае: генератар заўсёды яго выконвае", + "Soft: the generator keeps it where it can": "Гнуткае: генератар выконвае яго, калі можа", + "Weight (1 to 3)": "Вага (ад 1 да 3)", + "Most lessons a day": "Не больш за столькі ўрокаў у дзень", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Настаўнік (імя карыстальніка)", + "Activity (group:subject)": "Заняткі (група:прадмет)", + "Say who or what the wish is about.": "Пазначце, каго ці чаго датычыць пажаданне.", + "Choose at least one period.": "Выберыце хаця б адзін урок.", + "The limit is a whole number of 1 or more.": "Ліміт: цэлы лік ад 1.", + "Timetable": "Расклад", + "Wishes": "Пажаданні", + "Timetable wishes": "Пажаданні да раскладу" }, "plurals": {} } diff --git a/l10n/bg.js b/l10n/bg.js index 81136283..c96ada3b 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Защо изпълнението е неуспешно", "Published at": "Публикуван на", - "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове" + "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове", + "Edit wish": "Редактиране на желание", + "New wish": "Ново желание", + "Hard: the generator always keeps it": "Задължително: генераторът винаги го спазва", + "Soft: the generator keeps it where it can": "Гъвкаво: генераторът го спазва, когато може", + "Weight (1 to 3)": "Тежест (от 1 до 3)", + "Most lessons a day": "Най-много часове на ден", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Учител (потребителско име)", + "Activity (group:subject)": "Дейност (група:предмет)", + "Say who or what the wish is about.": "Посочете за кого или за какво е желанието.", + "Choose at least one period.": "Изберете поне един час.", + "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", + "Timetable": "Разписание", + "Wishes": "Желания", + "Timetable wishes": "Желания за разписанието" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index e73ba4d1..da51ca2b 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Защо изпълнението е неуспешно", "Published at": "Публикуван на", - "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове" + "When the scenario was written out as draft lessons": "Кога сценарият е записан като чернови часове", + "Edit wish": "Редактиране на желание", + "New wish": "Ново желание", + "Hard: the generator always keeps it": "Задължително: генераторът винаги го спазва", + "Soft: the generator keeps it where it can": "Гъвкаво: генераторът го спазва, когато може", + "Weight (1 to 3)": "Тежест (от 1 до 3)", + "Most lessons a day": "Най-много часове на ден", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Учител (потребителско име)", + "Activity (group:subject)": "Дейност (група:предмет)", + "Say who or what the wish is about.": "Посочете за кого или за какво е желанието.", + "Choose at least one period.": "Изберете поне един час.", + "The limit is a whole number of 1 or more.": "Лимитът е цяло число от 1 нагоре.", + "Timetable": "Разписание", + "Wishes": "Желания", + "Timetable wishes": "Желания за разписанието" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index e5d2ea1b..fd4bd0f5 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše časova dnevno", + "Period": "Čas", + "{day} period {number}": "{day} čas {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili šta se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan čas.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index d9872360..5e0c4b7d 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti časova", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše časova dnevno", + "Period": "Čas", + "{day} period {number}": "{day} čas {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili šta se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan čas.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index d68871b0..ca9d46e6 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiu", "Why the run failed": "Per què ha fallat l'execució", "Published at": "Publicat el", - "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany" + "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany", + "Edit wish": "Edita el desig", + "New wish": "Desig nou", + "Hard: the generator always keeps it": "Estricte: el generador sempre el respecta", + "Soft: the generator keeps it where it can": "Flexible: el generador el respecta quan pot", + "Weight (1 to 3)": "Pes (d'1 a 3)", + "Most lessons a day": "Màxim de classes al dia", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docent (nom d'usuari)", + "Activity (group:subject)": "Activitat (grup:assignatura)", + "Say who or what the wish is about.": "Indiqueu a qui o a què es refereix el desig.", + "Choose at least one period.": "Trieu almenys una franja.", + "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", + "Timetable": "Horari", + "Wishes": "Desitjos", + "Timetable wishes": "Desitjos d'horari" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index 0c8a732d..ca6f1406 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1007,7 +1007,23 @@ "Reason": "Motiu", "Why the run failed": "Per què ha fallat l'execució", "Published at": "Publicat el", - "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany" + "When the scenario was written out as draft lessons": "Quan es va escriure l'escenari com a classes esborrany", + "Edit wish": "Edita el desig", + "New wish": "Desig nou", + "Hard: the generator always keeps it": "Estricte: el generador sempre el respecta", + "Soft: the generator keeps it where it can": "Flexible: el generador el respecta quan pot", + "Weight (1 to 3)": "Pes (d'1 a 3)", + "Most lessons a day": "Màxim de classes al dia", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docent (nom d'usuari)", + "Activity (group:subject)": "Activitat (grup:assignatura)", + "Say who or what the wish is about.": "Indiqueu a qui o a què es refereix el desig.", + "Choose at least one period.": "Trieu almenys una franja.", + "The limit is a whole number of 1 or more.": "El límit és un nombre enter d'1 o més.", + "Timetable": "Horari", + "Wishes": "Desitjos", + "Timetable wishes": "Desitjos d'horari" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index dc0c2e0b..e270e46e 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Důvod", "Why the run failed": "Proč běh selhal", "Published at": "Zveřejněno", - "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin" + "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin", + "Edit wish": "Upravit přání", + "New wish": "Nové přání", + "Hard: the generator always keeps it": "Pevné: generátor je vždy dodrží", + "Soft: the generator keeps it where it can": "Měkké: generátor je dodrží, kde to jde", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Nejvýše hodin denně", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učitel (uživatelské jméno)", + "Activity (group:subject)": "Aktivita (skupina:předmět)", + "Say who or what the wish is about.": "Uveďte, koho nebo čeho se přání týká.", + "Choose at least one period.": "Vyberte alespoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", + "Timetable": "Rozvrh", + "Wishes": "Přání", + "Timetable wishes": "Přání k rozvrhu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 69c1006c..39c076c6 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1007,7 +1007,23 @@ "Reason": "Důvod", "Why the run failed": "Proč běh selhal", "Published at": "Zveřejněno", - "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin" + "When the scenario was written out as draft lessons": "Kdy byl scénář zapsán jako koncepty hodin", + "Edit wish": "Upravit přání", + "New wish": "Nové přání", + "Hard: the generator always keeps it": "Pevné: generátor je vždy dodrží", + "Soft: the generator keeps it where it can": "Měkké: generátor je dodrží, kde to jde", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Nejvýše hodin denně", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učitel (uživatelské jméno)", + "Activity (group:subject)": "Aktivita (skupina:předmět)", + "Say who or what the wish is about.": "Uveďte, koho nebo čeho se přání týká.", + "Choose at least one period.": "Vyberte alespoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 nebo vyšší.", + "Timetable": "Rozvrh", + "Wishes": "Přání", + "Timetable wishes": "Přání k rozvrhu" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index 3bbeff32..2749c71f 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Årsag", "Why the run failed": "Hvorfor kørslen mislykkedes", "Published at": "Udgivet", - "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner" + "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner", + "Edit wish": "Rediger ønske", + "New wish": "Nyt ønske", + "Hard: the generator always keeps it": "Hårdt: generatoren overholder det altid", + "Soft: the generator keeps it where it can": "Blødt: generatoren overholder det, hvor den kan", + "Weight (1 to 3)": "Vægt (1 til 3)", + "Most lessons a day": "Højst antal lektioner om dagen", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lærer (brugernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Angiv hvem eller hvad ønsket gælder.", + "Choose at least one period.": "Vælg mindst én lektion.", + "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", + "Timetable": "Skema", + "Wishes": "Ønsker", + "Timetable wishes": "Skemaønsker" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 0a6f81f2..3b49229a 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1007,7 +1007,23 @@ "Reason": "Årsag", "Why the run failed": "Hvorfor kørslen mislykkedes", "Published at": "Udgivet", - "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner" + "When the scenario was written out as draft lessons": "Hvornår scenariet blev skrevet ud som kladdelektioner", + "Edit wish": "Rediger ønske", + "New wish": "Nyt ønske", + "Hard: the generator always keeps it": "Hårdt: generatoren overholder det altid", + "Soft: the generator keeps it where it can": "Blødt: generatoren overholder det, hvor den kan", + "Weight (1 to 3)": "Vægt (1 til 3)", + "Most lessons a day": "Højst antal lektioner om dagen", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lærer (brugernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Angiv hvem eller hvad ønsket gælder.", + "Choose at least one period.": "Vælg mindst én lektion.", + "The limit is a whole number of 1 or more.": "Grænsen er et helt tal på 1 eller mere.", + "Timetable": "Skema", + "Wishes": "Ønsker", + "Timetable wishes": "Skemaønsker" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index aefb9ccd..8d26d5a7 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Grund", "Why the run failed": "Warum der Lauf fehlschlug", "Published at": "Veröffentlicht am", - "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde" + "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde", + "Edit wish": "Wunsch bearbeiten", + "New wish": "Neuer Wunsch", + "Hard: the generator always keeps it": "Hart: der Generator hält ihn immer ein", + "Soft: the generator keeps it where it can": "Weich: der Generator hält ihn ein, wo er kann", + "Weight (1 to 3)": "Gewicht (1 bis 3)", + "Most lessons a day": "Höchstens Stunden pro Tag", + "Period": "Stunde", + "{day} period {number}": "{day} Stunde {number}", + "Teacher (user name)": "Lehrkraft (Benutzername)", + "Activity (group:subject)": "Aktivität (Gruppe:Fach)", + "Say who or what the wish is about.": "Geben Sie an, wen oder was der Wunsch betrifft.", + "Choose at least one period.": "Wählen Sie mindestens eine Stunde.", + "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", + "Timetable": "Stundenplan", + "Wishes": "Wünsche", + "Timetable wishes": "Stundenplanwünsche" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index eef9e8af..5fc84b4f 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1007,7 +1007,23 @@ "Reason": "Grund", "Why the run failed": "Warum der Lauf fehlschlug", "Published at": "Veröffentlicht am", - "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde" + "When the scenario was written out as draft lessons": "Wann das Szenario als Entwurfsstunden geschrieben wurde", + "Edit wish": "Wunsch bearbeiten", + "New wish": "Neuer Wunsch", + "Hard: the generator always keeps it": "Hart: der Generator hält ihn immer ein", + "Soft: the generator keeps it where it can": "Weich: der Generator hält ihn ein, wo er kann", + "Weight (1 to 3)": "Gewicht (1 bis 3)", + "Most lessons a day": "Höchstens Stunden pro Tag", + "Period": "Stunde", + "{day} period {number}": "{day} Stunde {number}", + "Teacher (user name)": "Lehrkraft (Benutzername)", + "Activity (group:subject)": "Aktivität (Gruppe:Fach)", + "Say who or what the wish is about.": "Geben Sie an, wen oder was der Wunsch betrifft.", + "Choose at least one period.": "Wählen Sie mindestens eine Stunde.", + "The limit is a whole number of 1 or more.": "Das Limit ist eine ganze Zahl ab 1.", + "Timetable": "Stundenplan", + "Wishes": "Wünsche", + "Timetable wishes": "Stundenplanwünsche" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index a813d4d1..f963e68d 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Αιτία", "Why the run failed": "Γιατί απέτυχε η εκτέλεση", "Published at": "Δημοσιεύτηκε στις", - "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα" + "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα", + "Edit wish": "Επεξεργασία επιθυμίας", + "New wish": "Νέα επιθυμία", + "Hard: the generator always keeps it": "Υποχρεωτική: η γεννήτρια την τηρεί πάντα", + "Soft: the generator keeps it where it can": "Ευέλικτη: η γεννήτρια την τηρεί όπου μπορεί", + "Weight (1 to 3)": "Βάρος (1 έως 3)", + "Most lessons a day": "Το πολύ μαθήματα την ημέρα", + "Period": "Ώρα", + "{day} period {number}": "{day} ώρα {number}", + "Teacher (user name)": "Εκπαιδευτικός (όνομα χρήστη)", + "Activity (group:subject)": "Δραστηριότητα (ομάδα:μάθημα)", + "Say who or what the wish is about.": "Δηλώστε ποιον ή τι αφορά η επιθυμία.", + "Choose at least one period.": "Επιλέξτε τουλάχιστον μία ώρα.", + "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", + "Timetable": "Ωρολόγιο πρόγραμμα", + "Wishes": "Επιθυμίες", + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 6dd0518e..3c39475e 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1007,7 +1007,23 @@ "Reason": "Αιτία", "Why the run failed": "Γιατί απέτυχε η εκτέλεση", "Published at": "Δημοσιεύτηκε στις", - "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα" + "When the scenario was written out as draft lessons": "Πότε γράφτηκε το σενάριο ως πρόχειρα μαθήματα", + "Edit wish": "Επεξεργασία επιθυμίας", + "New wish": "Νέα επιθυμία", + "Hard: the generator always keeps it": "Υποχρεωτική: η γεννήτρια την τηρεί πάντα", + "Soft: the generator keeps it where it can": "Ευέλικτη: η γεννήτρια την τηρεί όπου μπορεί", + "Weight (1 to 3)": "Βάρος (1 έως 3)", + "Most lessons a day": "Το πολύ μαθήματα την ημέρα", + "Period": "Ώρα", + "{day} period {number}": "{day} ώρα {number}", + "Teacher (user name)": "Εκπαιδευτικός (όνομα χρήστη)", + "Activity (group:subject)": "Δραστηριότητα (ομάδα:μάθημα)", + "Say who or what the wish is about.": "Δηλώστε ποιον ή τι αφορά η επιθυμία.", + "Choose at least one period.": "Επιλέξτε τουλάχιστον μία ώρα.", + "The limit is a whole number of 1 or more.": "Το όριο είναι ακέραιος αριθμός 1 ή μεγαλύτερος.", + "Timetable": "Ωρολόγιο πρόγραμμα", + "Wishes": "Επιθυμίες", + "Timetable wishes": "Επιθυμίες ωρολογίου προγράμματος" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 6fe2c3ab..a1c7c783 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -986,7 +986,23 @@ OC.L10N.register( "Reason": "Reason", "Why the run failed": "Why the run failed", "Published at": "Published at", - "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons" + "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons", + "Edit wish": "Edit wish", + "New wish": "New wish", + "Hard: the generator always keeps it": "Hard: the generator always keeps it", + "Soft: the generator keeps it where it can": "Soft: the generator keeps it where it can", + "Weight (1 to 3)": "Weight (1 to 3)", + "Most lessons a day": "Most lessons a day", + "Period": "Period", + "{day} period {number}": "{day} period {number}", + "Teacher (user name)": "Teacher (user name)", + "Activity (group:subject)": "Activity (group:subject)", + "Say who or what the wish is about.": "Say who or what the wish is about.", + "Choose at least one period.": "Choose at least one period.", + "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", + "Timetable": "Timetable", + "Wishes": "Wishes", + "Timetable wishes": "Timetable wishes" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index f551fa56..f3a66683 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -985,7 +985,23 @@ "Reason": "Reason", "Why the run failed": "Why the run failed", "Published at": "Published at", - "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons" + "When the scenario was written out as draft lessons": "When the scenario was written out as draft lessons", + "Edit wish": "Edit wish", + "New wish": "New wish", + "Hard: the generator always keeps it": "Hard: the generator always keeps it", + "Soft: the generator keeps it where it can": "Soft: the generator keeps it where it can", + "Weight (1 to 3)": "Weight (1 to 3)", + "Most lessons a day": "Most lessons a day", + "Period": "Period", + "{day} period {number}": "{day} period {number}", + "Teacher (user name)": "Teacher (user name)", + "Activity (group:subject)": "Activity (group:subject)", + "Say who or what the wish is about.": "Say who or what the wish is about.", + "Choose at least one period.": "Choose at least one period.", + "The limit is a whole number of 1 or more.": "The limit is a whole number of 1 or more.", + "Timetable": "Timetable", + "Wishes": "Wishes", + "Timetable wishes": "Timetable wishes" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index d809f625..d6a918db 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Por qué falló la ejecución", "Published at": "Publicado el", - "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador" + "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador", + "Edit wish": "Editar deseo", + "New wish": "Nuevo deseo", + "Hard: the generator always keeps it": "Estricto: el generador siempre lo respeta", + "Soft: the generator keeps it where it can": "Flexible: el generador lo respeta cuando puede", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de clases al día", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docente (nombre de usuario)", + "Activity (group:subject)": "Actividad (grupo:asignatura)", + "Say who or what the wish is about.": "Indique a quién o a qué se refiere el deseo.", + "Choose at least one period.": "Elija al menos una franja.", + "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", + "Timetable": "Horario", + "Wishes": "Deseos", + "Timetable wishes": "Deseos de horario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index b2aa9898..8e0a49e6 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Por qué falló la ejecución", "Published at": "Publicado el", - "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador" + "When the scenario was written out as draft lessons": "Cuándo se escribió el escenario como clases borrador", + "Edit wish": "Editar deseo", + "New wish": "Nuevo deseo", + "Hard: the generator always keeps it": "Estricto: el generador siempre lo respeta", + "Soft: the generator keeps it where it can": "Flexible: el generador lo respeta cuando puede", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de clases al día", + "Period": "Franja", + "{day} period {number}": "{day} franja {number}", + "Teacher (user name)": "Docente (nombre de usuario)", + "Activity (group:subject)": "Actividad (grupo:asignatura)", + "Say who or what the wish is about.": "Indique a quién o a qué se refiere el deseo.", + "Choose at least one period.": "Elija al menos una franja.", + "The limit is a whole number of 1 or more.": "El límite es un número entero de 1 o más.", + "Timetable": "Horario", + "Wishes": "Deseos", + "Timetable wishes": "Deseos de horario" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index cf714176..90b2e52d 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Põhjus", "Why the run failed": "Miks käivitus ebaõnnestus", "Published at": "Avaldatud", - "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks" + "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks", + "Edit wish": "Muuda soovi", + "New wish": "Uus soov", + "Hard: the generator always keeps it": "Kohustuslik: generaator peab sellest alati kinni", + "Soft: the generator keeps it where it can": "Paindlik: generaator peab sellest kinni, kui saab", + "Weight (1 to 3)": "Kaal (1 kuni 3)", + "Most lessons a day": "Kõige rohkem tunde päevas", + "Period": "Tund", + "{day} period {number}": "{day} tund {number}", + "Teacher (user name)": "Õpetaja (kasutajanimi)", + "Activity (group:subject)": "Tegevus (rühm:aine)", + "Say who or what the wish is about.": "Märkige, kelle või mille kohta soov on.", + "Choose at least one period.": "Valige vähemalt üks tund.", + "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", + "Timetable": "Tunniplaan", + "Wishes": "Soovid", + "Timetable wishes": "Tunniplaani soovid" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 132f4156..fe6c19ba 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1007,7 +1007,23 @@ "Reason": "Põhjus", "Why the run failed": "Miks käivitus ebaõnnestus", "Published at": "Avaldatud", - "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks" + "When the scenario was written out as draft lessons": "Millal stsenaarium kirjutati mustandtundideks", + "Edit wish": "Muuda soovi", + "New wish": "Uus soov", + "Hard: the generator always keeps it": "Kohustuslik: generaator peab sellest alati kinni", + "Soft: the generator keeps it where it can": "Paindlik: generaator peab sellest kinni, kui saab", + "Weight (1 to 3)": "Kaal (1 kuni 3)", + "Most lessons a day": "Kõige rohkem tunde päevas", + "Period": "Tund", + "{day} period {number}": "{day} tund {number}", + "Teacher (user name)": "Õpetaja (kasutajanimi)", + "Activity (group:subject)": "Tegevus (rühm:aine)", + "Say who or what the wish is about.": "Märkige, kelle või mille kohta soov on.", + "Choose at least one period.": "Valige vähemalt üks tund.", + "The limit is a whole number of 1 or more.": "Piirang on täisarv 1 või rohkem.", + "Timetable": "Tunniplaan", + "Wishes": "Soovid", + "Timetable wishes": "Tunniplaani soovid" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index 007249c5..b43ba315 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Syy", "Why the run failed": "Miksi ajo epäonnistui", "Published at": "Julkaistu", - "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi" + "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi", + "Edit wish": "Muokkaa toivetta", + "New wish": "Uusi toive", + "Hard: the generator always keeps it": "Ehdoton: generaattori noudattaa sitä aina", + "Soft: the generator keeps it where it can": "Joustava: generaattori noudattaa sitä, kun voi", + "Weight (1 to 3)": "Paino (1-3)", + "Most lessons a day": "Enintään tunteja päivässä", + "Period": "Tunti", + "{day} period {number}": "{day} tunti {number}", + "Teacher (user name)": "Opettaja (käyttäjänimi)", + "Activity (group:subject)": "Toiminta (ryhmä:aine)", + "Say who or what the wish is about.": "Kerro, ketä tai mitä toive koskee.", + "Choose at least one period.": "Valitse vähintään yksi tunti.", + "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", + "Timetable": "Lukujärjestys", + "Wishes": "Toiveet", + "Timetable wishes": "Lukujärjestystoiveet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 888d9764..4bb5cc6d 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1007,7 +1007,23 @@ "Reason": "Syy", "Why the run failed": "Miksi ajo epäonnistui", "Published at": "Julkaistu", - "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi" + "When the scenario was written out as draft lessons": "Milloin skenaario kirjoitettiin luonnostunneiksi", + "Edit wish": "Muokkaa toivetta", + "New wish": "Uusi toive", + "Hard: the generator always keeps it": "Ehdoton: generaattori noudattaa sitä aina", + "Soft: the generator keeps it where it can": "Joustava: generaattori noudattaa sitä, kun voi", + "Weight (1 to 3)": "Paino (1-3)", + "Most lessons a day": "Enintään tunteja päivässä", + "Period": "Tunti", + "{day} period {number}": "{day} tunti {number}", + "Teacher (user name)": "Opettaja (käyttäjänimi)", + "Activity (group:subject)": "Toiminta (ryhmä:aine)", + "Say who or what the wish is about.": "Kerro, ketä tai mitä toive koskee.", + "Choose at least one period.": "Valitse vähintään yksi tunti.", + "The limit is a whole number of 1 or more.": "Raja on kokonaisluku 1 tai enemmän.", + "Timetable": "Lukujärjestys", + "Wishes": "Toiveet", + "Timetable wishes": "Lukujärjestystoiveet" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index 73238b14..196a2085 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Raison", "Why the run failed": "Pourquoi le calcul a échoué", "Published at": "Publié le", - "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons" + "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons", + "Edit wish": "Modifier le souhait", + "New wish": "Nouveau souhait", + "Hard: the generator always keeps it": "Strict : le générateur le respecte toujours", + "Soft: the generator keeps it where it can": "Souple : le générateur le respecte quand il le peut", + "Weight (1 to 3)": "Poids (1 à 3)", + "Most lessons a day": "Nombre maximal de cours par jour", + "Period": "Créneau", + "{day} period {number}": "{day} créneau {number}", + "Teacher (user name)": "Enseignant (nom d'utilisateur)", + "Activity (group:subject)": "Activité (groupe:matière)", + "Say who or what the wish is about.": "Indiquez qui ou quoi concerne le souhait.", + "Choose at least one period.": "Choisissez au moins un créneau.", + "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", + "Timetable": "Emploi du temps", + "Wishes": "Souhaits", + "Timetable wishes": "Souhaits d'emploi du temps" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 1f7b947f..2b668abd 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1007,7 +1007,23 @@ "Reason": "Raison", "Why the run failed": "Pourquoi le calcul a échoué", "Published at": "Publié le", - "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons" + "When the scenario was written out as draft lessons": "Quand le scénario a été écrit en cours brouillons", + "Edit wish": "Modifier le souhait", + "New wish": "Nouveau souhait", + "Hard: the generator always keeps it": "Strict : le générateur le respecte toujours", + "Soft: the generator keeps it where it can": "Souple : le générateur le respecte quand il le peut", + "Weight (1 to 3)": "Poids (1 à 3)", + "Most lessons a day": "Nombre maximal de cours par jour", + "Period": "Créneau", + "{day} period {number}": "{day} créneau {number}", + "Teacher (user name)": "Enseignant (nom d'utilisateur)", + "Activity (group:subject)": "Activité (groupe:matière)", + "Say who or what the wish is about.": "Indiquez qui ou quoi concerne le souhait.", + "Choose at least one period.": "Choisissez au moins un créneau.", + "The limit is a whole number of 1 or more.": "La limite est un nombre entier de 1 ou plus.", + "Timetable": "Emploi du temps", + "Wishes": "Souhaits", + "Timetable wishes": "Souhaits d'emploi du temps" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index 8f460dfb..684e90ee 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Cúis", "Why the run failed": "Cén fáth ar theip ar an rith", "Published at": "Foilsithe ar", - "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna" + "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna", + "Edit wish": "Cuir mian in eagar", + "New wish": "Mian nua", + "Hard: the generator always keeps it": "Docht: coinníonn an gineadóir i gcónaí é", + "Soft: the generator keeps it where it can": "Bog: coinníonn an gineadóir é nuair is féidir", + "Weight (1 to 3)": "Meáchan (1 go 3)", + "Most lessons a day": "Uasmhéid ceachtanna in aghaidh an lae", + "Period": "Tréimhse", + "{day} period {number}": "{day} tréimhse {number}", + "Teacher (user name)": "Múinteoir (ainm úsáideora)", + "Activity (group:subject)": "Gníomhaíocht (grúpa:ábhar)", + "Say who or what the wish is about.": "Luaigh cé nó cad lena mbaineann an mian.", + "Choose at least one period.": "Roghnaigh tréimhse amháin ar a laghad.", + "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", + "Timetable": "Clár ama", + "Wishes": "Mianta", + "Timetable wishes": "Mianta an chláir ama" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index d0a50576..c647eb2b 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1007,7 +1007,23 @@ "Reason": "Cúis", "Why the run failed": "Cén fáth ar theip ar an rith", "Published at": "Foilsithe ar", - "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna" + "When the scenario was written out as draft lessons": "Cathain a scríobhadh an cás mar dhréachtcheachtanna", + "Edit wish": "Cuir mian in eagar", + "New wish": "Mian nua", + "Hard: the generator always keeps it": "Docht: coinníonn an gineadóir i gcónaí é", + "Soft: the generator keeps it where it can": "Bog: coinníonn an gineadóir é nuair is féidir", + "Weight (1 to 3)": "Meáchan (1 go 3)", + "Most lessons a day": "Uasmhéid ceachtanna in aghaidh an lae", + "Period": "Tréimhse", + "{day} period {number}": "{day} tréimhse {number}", + "Teacher (user name)": "Múinteoir (ainm úsáideora)", + "Activity (group:subject)": "Gníomhaíocht (grúpa:ábhar)", + "Say who or what the wish is about.": "Luaigh cé nó cad lena mbaineann an mian.", + "Choose at least one period.": "Roghnaigh tréimhse amháin ar a laghad.", + "The limit is a whole number of 1 or more.": "Is slánuimhir 1 nó níos mó an teorainn.", + "Timetable": "Clár ama", + "Wishes": "Mianta", + "Timetable wishes": "Mianta an chláir ama" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 22c6c27e..81d77966 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše sati dnevno", + "Period": "Sat", + "{day} period {number}": "{day} sat {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili što se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan sat.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index 62a8b373..c291b044 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspjelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati" + "When the scenario was written out as draft lessons": "Kada je scenarij zapisan kao nacrti sati", + "Edit wish": "Uredi želju", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obavezno: generator je uvijek poštuje", + "Soft: the generator keeps it where it can": "Fleksibilno: generator je poštuje gdje može", + "Weight (1 to 3)": "Težina (1 do 3)", + "Most lessons a day": "Najviše sati dnevno", + "Period": "Sat", + "{day} period {number}": "{day} sat {number}", + "Teacher (user name)": "Nastavnik (korisničko ime)", + "Activity (group:subject)": "Aktivnost (grupa:predmet)", + "Say who or what the wish is about.": "Navedite na koga ili što se želja odnosi.", + "Choose at least one period.": "Odaberite barem jedan sat.", + "The limit is a whole number of 1 or more.": "Ograničenje je cijeli broj od 1 ili više.", + "Timetable": "Raspored", + "Wishes": "Želje", + "Timetable wishes": "Želje za raspored" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 418cb9f0..120b3e27 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Ok", "Why the run failed": "Miért volt sikertelen a futás", "Published at": "Közzétéve", - "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként" + "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként", + "Edit wish": "Kívánság szerkesztése", + "New wish": "Új kívánság", + "Hard: the generator always keeps it": "Kötelező: a generátor mindig betartja", + "Soft: the generator keeps it where it can": "Rugalmas: a generátor betartja, ahol tudja", + "Weight (1 to 3)": "Súly (1-től 3-ig)", + "Most lessons a day": "Legfeljebb ennyi óra naponta", + "Period": "Óra", + "{day} period {number}": "{day} {number}. óra", + "Teacher (user name)": "Tanár (felhasználónév)", + "Activity (group:subject)": "Tevékenység (csoport:tantárgy)", + "Say who or what the wish is about.": "Adja meg, kire vagy mire vonatkozik a kívánság.", + "Choose at least one period.": "Válasszon legalább egy órát.", + "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", + "Timetable": "Órarend", + "Wishes": "Kívánságok", + "Timetable wishes": "Órarendi kívánságok" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index 702e783e..2d1c09e6 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1007,7 +1007,23 @@ "Reason": "Ok", "Why the run failed": "Miért volt sikertelen a futás", "Published at": "Közzétéve", - "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként" + "When the scenario was written out as draft lessons": "Mikor íródott ki a forgatókönyv piszkozatórákként", + "Edit wish": "Kívánság szerkesztése", + "New wish": "Új kívánság", + "Hard: the generator always keeps it": "Kötelező: a generátor mindig betartja", + "Soft: the generator keeps it where it can": "Rugalmas: a generátor betartja, ahol tudja", + "Weight (1 to 3)": "Súly (1-től 3-ig)", + "Most lessons a day": "Legfeljebb ennyi óra naponta", + "Period": "Óra", + "{day} period {number}": "{day} {number}. óra", + "Teacher (user name)": "Tanár (felhasználónév)", + "Activity (group:subject)": "Tevékenység (csoport:tantárgy)", + "Say who or what the wish is about.": "Adja meg, kire vagy mire vonatkozik a kívánság.", + "Choose at least one period.": "Válasszon legalább egy órát.", + "The limit is a whole number of 1 or more.": "A korlát legalább 1 egész szám.", + "Timetable": "Órarend", + "Wishes": "Kívánságok", + "Timetable wishes": "Órarendi kívánságok" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 0bf5e511..d5dd82b2 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Ástæða", "Why the run failed": "Hvers vegna keyrslan mistókst", "Published at": "Birt", - "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum" + "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum", + "Edit wish": "Breyta ósk", + "New wish": "Ný ósk", + "Hard: the generator always keeps it": "Föst: rafallinn virðir hana alltaf", + "Soft: the generator keeps it where it can": "Sveigjanleg: rafallinn virðir hana þar sem hann getur", + "Weight (1 to 3)": "Vægi (1 til 3)", + "Most lessons a day": "Mest kennslustundir á dag", + "Period": "Kennslustund", + "{day} period {number}": "{day} kennslustund {number}", + "Teacher (user name)": "Kennari (notandanafn)", + "Activity (group:subject)": "Verkefni (hópur:fag)", + "Say who or what the wish is about.": "Tilgreindu hvern eða hvað óskin varðar.", + "Choose at least one period.": "Veldu að minnsta kosti eina kennslustund.", + "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", + "Timetable": "Stundaskrá", + "Wishes": "Óskir", + "Timetable wishes": "Óskir um stundaskrá" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 14315c2b..695953ce 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1007,7 +1007,23 @@ "Reason": "Ástæða", "Why the run failed": "Hvers vegna keyrslan mistókst", "Published at": "Birt", - "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum" + "When the scenario was written out as draft lessons": "Hvenær drögin voru skrifuð út sem drög að kennslustundum", + "Edit wish": "Breyta ósk", + "New wish": "Ný ósk", + "Hard: the generator always keeps it": "Föst: rafallinn virðir hana alltaf", + "Soft: the generator keeps it where it can": "Sveigjanleg: rafallinn virðir hana þar sem hann getur", + "Weight (1 to 3)": "Vægi (1 til 3)", + "Most lessons a day": "Mest kennslustundir á dag", + "Period": "Kennslustund", + "{day} period {number}": "{day} kennslustund {number}", + "Teacher (user name)": "Kennari (notandanafn)", + "Activity (group:subject)": "Verkefni (hópur:fag)", + "Say who or what the wish is about.": "Tilgreindu hvern eða hvað óskin varðar.", + "Choose at least one period.": "Veldu að minnsta kosti eina kennslustund.", + "The limit is a whole number of 1 or more.": "Takmarkið er heiltala, 1 eða hærri.", + "Timetable": "Stundaskrá", + "Wishes": "Óskir", + "Timetable wishes": "Óskir um stundaskrá" }, "plurals": {} } diff --git a/l10n/it.js b/l10n/it.js index 28438919..84f734c1 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Perché l'esecuzione è fallita", "Published at": "Pubblicato il", - "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza" + "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza", + "Edit wish": "Modifica desiderio", + "New wish": "Nuovo desiderio", + "Hard: the generator always keeps it": "Vincolante: il generatore lo rispetta sempre", + "Soft: the generator keeps it where it can": "Flessibile: il generatore lo rispetta quando può", + "Weight (1 to 3)": "Peso (da 1 a 3)", + "Most lessons a day": "Massimo di lezioni al giorno", + "Period": "Ora", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Docente (nome utente)", + "Activity (group:subject)": "Attività (gruppo:materia)", + "Say who or what the wish is about.": "Indica chi o cosa riguarda il desiderio.", + "Choose at least one period.": "Scegli almeno un'ora.", + "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", + "Timetable": "Orario", + "Wishes": "Desideri", + "Timetable wishes": "Desideri per l'orario" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 4d83da62..8a398fd3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Perché l'esecuzione è fallita", "Published at": "Pubblicato il", - "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza" + "When the scenario was written out as draft lessons": "Quando lo scenario è stato scritto come lezioni bozza", + "Edit wish": "Modifica desiderio", + "New wish": "Nuovo desiderio", + "Hard: the generator always keeps it": "Vincolante: il generatore lo rispetta sempre", + "Soft: the generator keeps it where it can": "Flessibile: il generatore lo rispetta quando può", + "Weight (1 to 3)": "Peso (da 1 a 3)", + "Most lessons a day": "Massimo di lezioni al giorno", + "Period": "Ora", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Docente (nome utente)", + "Activity (group:subject)": "Attività (gruppo:materia)", + "Say who or what the wish is about.": "Indica chi o cosa riguarda il desiderio.", + "Choose at least one period.": "Scegli almeno un'ora.", + "The limit is a whole number of 1 or more.": "Il limite è un numero intero pari o superiore a 1.", + "Timetable": "Orario", + "Wishes": "Desideri", + "Timetable wishes": "Desideri per l'orario" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 056e0794..65a51e67 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Grond", "Why the run failed": "Firwat de Laf feelgeschloen ass", "Published at": "Publizéiert den", - "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf" + "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf", + "Edit wish": "Wonsch änneren", + "New wish": "Neie Wonsch", + "Hard: the generator always keeps it": "Fix: de Generator hält sech ëmmer drun", + "Soft: the generator keeps it where it can": "Flexibel: de Generator hält sech drun, wou e kann", + "Weight (1 to 3)": "Gewiicht (1 bis 3)", + "Most lessons a day": "Héchstens Stonnen den Dag", + "Period": "Stonn", + "{day} period {number}": "{day} Stonn {number}", + "Teacher (user name)": "Enseignant (Benotzernumm)", + "Activity (group:subject)": "Aktivitéit (Grupp:Fach)", + "Say who or what the wish is about.": "Gitt un, wien oder wat de Wonsch betrëfft.", + "Choose at least one period.": "Wielt mindestens eng Stonn.", + "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", + "Timetable": "Stonneplang", + "Wishes": "Wënsch", + "Timetable wishes": "Stonneplangwënsch" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 693cb28d..6a87037e 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1007,7 +1007,23 @@ "Reason": "Grond", "Why the run failed": "Firwat de Laf feelgeschloen ass", "Published at": "Publizéiert den", - "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf" + "When the scenario was written out as draft lessons": "Wéini d'Szenario als Entworfsstonne geschriwwe gouf", + "Edit wish": "Wonsch änneren", + "New wish": "Neie Wonsch", + "Hard: the generator always keeps it": "Fix: de Generator hält sech ëmmer drun", + "Soft: the generator keeps it where it can": "Flexibel: de Generator hält sech drun, wou e kann", + "Weight (1 to 3)": "Gewiicht (1 bis 3)", + "Most lessons a day": "Héchstens Stonnen den Dag", + "Period": "Stonn", + "{day} period {number}": "{day} Stonn {number}", + "Teacher (user name)": "Enseignant (Benotzernumm)", + "Activity (group:subject)": "Aktivitéit (Grupp:Fach)", + "Say who or what the wish is about.": "Gitt un, wien oder wat de Wonsch betrëfft.", + "Choose at least one period.": "Wielt mindestens eng Stonn.", + "The limit is a whole number of 1 or more.": "D'Limit ass eng ganz Zuel vun 1 oder méi.", + "Timetable": "Stonneplang", + "Wishes": "Wënsch", + "Timetable wishes": "Stonneplangwënsch" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 167a2c82..423106ef 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Priežastis", "Why the run failed": "Kodėl paleidimas nepavyko", "Published at": "Paskelbta", - "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos" + "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos", + "Edit wish": "Redaguoti pageidavimą", + "New wish": "Naujas pageidavimas", + "Hard: the generator always keeps it": "Privalomas: generatorius jo visada laikosi", + "Soft: the generator keeps it where it can": "Lankstus: generatorius jo laikosi, kai gali", + "Weight (1 to 3)": "Svoris (nuo 1 iki 3)", + "Most lessons a day": "Daugiausia pamokų per dieną", + "Period": "Pamoka", + "{day} period {number}": "{day} {number} pamoka", + "Teacher (user name)": "Mokytojas (naudotojo vardas)", + "Activity (group:subject)": "Veikla (grupė:dalykas)", + "Say who or what the wish is about.": "Nurodykite, ko pageidavimas liečia.", + "Choose at least one period.": "Pasirinkite bent vieną pamoką.", + "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", + "Timetable": "Tvarkaraštis", + "Wishes": "Pageidavimai", + "Timetable wishes": "Tvarkaraščio pageidavimai" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index a9f1daad..2cc512b0 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1007,7 +1007,23 @@ "Reason": "Priežastis", "Why the run failed": "Kodėl paleidimas nepavyko", "Published at": "Paskelbta", - "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos" + "When the scenario was written out as draft lessons": "Kada scenarijus buvo įrašytas kaip juodraštinės pamokos", + "Edit wish": "Redaguoti pageidavimą", + "New wish": "Naujas pageidavimas", + "Hard: the generator always keeps it": "Privalomas: generatorius jo visada laikosi", + "Soft: the generator keeps it where it can": "Lankstus: generatorius jo laikosi, kai gali", + "Weight (1 to 3)": "Svoris (nuo 1 iki 3)", + "Most lessons a day": "Daugiausia pamokų per dieną", + "Period": "Pamoka", + "{day} period {number}": "{day} {number} pamoka", + "Teacher (user name)": "Mokytojas (naudotojo vardas)", + "Activity (group:subject)": "Veikla (grupė:dalykas)", + "Say who or what the wish is about.": "Nurodykite, ko pageidavimas liečia.", + "Choose at least one period.": "Pasirinkite bent vieną pamoką.", + "The limit is a whole number of 1 or more.": "Riba yra sveikasis skaičius, 1 ar daugiau.", + "Timetable": "Tvarkaraštis", + "Wishes": "Pageidavimai", + "Timetable wishes": "Tvarkaraščio pageidavimai" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index a049989b..0c631d2d 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Iemesls", "Why the run failed": "Kāpēc palaišana neizdevās", "Published at": "Publicēts", - "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas" + "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas", + "Edit wish": "Rediģēt vēlmi", + "New wish": "Jauna vēlme", + "Hard: the generator always keeps it": "Obligāta: ģenerators to vienmēr ievēro", + "Soft: the generator keeps it where it can": "Elastīga: ģenerators to ievēro, kur var", + "Weight (1 to 3)": "Svars (no 1 līdz 3)", + "Most lessons a day": "Ne vairāk stundu dienā", + "Period": "Stunda", + "{day} period {number}": "{day} {number}. stunda", + "Teacher (user name)": "Skolotājs (lietotājvārds)", + "Activity (group:subject)": "Aktivitāte (grupa:priekšmets)", + "Say who or what the wish is about.": "Norādiet, uz ko vēlme attiecas.", + "Choose at least one period.": "Izvēlieties vismaz vienu stundu.", + "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", + "Timetable": "Stundu saraksts", + "Wishes": "Vēlmes", + "Timetable wishes": "Stundu saraksta vēlmes" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index e253cd7b..0804406e 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1007,7 +1007,23 @@ "Reason": "Iemesls", "Why the run failed": "Kāpēc palaišana neizdevās", "Published at": "Publicēts", - "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas" + "When the scenario was written out as draft lessons": "Kad scenārijs tika ierakstīts kā melnraksta stundas", + "Edit wish": "Rediģēt vēlmi", + "New wish": "Jauna vēlme", + "Hard: the generator always keeps it": "Obligāta: ģenerators to vienmēr ievēro", + "Soft: the generator keeps it where it can": "Elastīga: ģenerators to ievēro, kur var", + "Weight (1 to 3)": "Svars (no 1 līdz 3)", + "Most lessons a day": "Ne vairāk stundu dienā", + "Period": "Stunda", + "{day} period {number}": "{day} {number}. stunda", + "Teacher (user name)": "Skolotājs (lietotājvārds)", + "Activity (group:subject)": "Aktivitāte (grupa:priekšmets)", + "Say who or what the wish is about.": "Norādiet, uz ko vēlme attiecas.", + "Choose at least one period.": "Izvēlieties vismaz vienu stundu.", + "The limit is a whole number of 1 or more.": "Ierobežojums ir vesels skaitlis, 1 vai vairāk.", + "Timetable": "Stundu saraksts", + "Wishes": "Vēlmes", + "Timetable wishes": "Stundu saraksta vēlmes" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 8cec77f8..a7a889cd 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Зошто извршувањето не успеа", "Published at": "Објавено на", - "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови" + "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови", + "Edit wish": "Уреди желба", + "New wish": "Нова желба", + "Hard: the generator always keeps it": "Задолжителна: генераторот секогаш ја почитува", + "Soft: the generator keeps it where it can": "Флексибилна: генераторот ја почитува каде што може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Најмногу часови дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведете на кого или на што се однесува желбата.", + "Choose at least one period.": "Изберете барем еден час.", + "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", + "Timetable": "Распоред", + "Wishes": "Желби", + "Timetable wishes": "Желби за распоредот" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 9575a935..11492adf 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Зошто извршувањето не успеа", "Published at": "Објавено на", - "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови" + "When the scenario was written out as draft lessons": "Кога сценариото е запишано како нацрт-часови", + "Edit wish": "Уреди желба", + "New wish": "Нова желба", + "Hard: the generator always keeps it": "Задолжителна: генераторот секогаш ја почитува", + "Soft: the generator keeps it where it can": "Флексибилна: генераторот ја почитува каде што може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Најмногу часови дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведете на кого или на што се однесува желбата.", + "Choose at least one period.": "Изберете барем еден час.", + "The limit is a whole number of 1 or more.": "Ограничувањето е цел број 1 или повеќе.", + "Timetable": "Распоред", + "Wishes": "Желби", + "Timetable wishes": "Желби за распоредот" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index ea5a2717..b965074f 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Raġuni", "Why the run failed": "Għaliex falliet il-ġirja", "Published at": "Ippubblikat fi", - "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz" + "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz", + "Edit wish": "Editja x-xewqa", + "New wish": "Xewqa ġdida", + "Hard: the generator always keeps it": "Iebsa: il-ġeneratur dejjem iżommha", + "Soft: the generator keeps it where it can": "Ratba: il-ġeneratur iżommha fejn jista'", + "Weight (1 to 3)": "Piż (minn 1 sa 3)", + "Most lessons a day": "L-akbar numru ta' lezzjonijiet kuljum", + "Period": "Perjodu", + "{day} period {number}": "{day} perjodu {number}", + "Teacher (user name)": "Għalliem (isem tal-utent)", + "Activity (group:subject)": "Attività (grupp:suġġett)", + "Say who or what the wish is about.": "Għid dwar min jew dwar xiex hi x-xewqa.", + "Choose at least one period.": "Agħżel mill-inqas perjodu wieħed.", + "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", + "Timetable": "Orarju", + "Wishes": "Xewqat", + "Timetable wishes": "Xewqat għall-orarju" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 59ef6162..449db2a0 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1007,7 +1007,23 @@ "Reason": "Raġuni", "Why the run failed": "Għaliex falliet il-ġirja", "Published at": "Ippubblikat fi", - "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz" + "When the scenario was written out as draft lessons": "Meta x-xenarju nkiteb bħala lezzjonijiet abbozz", + "Edit wish": "Editja x-xewqa", + "New wish": "Xewqa ġdida", + "Hard: the generator always keeps it": "Iebsa: il-ġeneratur dejjem iżommha", + "Soft: the generator keeps it where it can": "Ratba: il-ġeneratur iżommha fejn jista'", + "Weight (1 to 3)": "Piż (minn 1 sa 3)", + "Most lessons a day": "L-akbar numru ta' lezzjonijiet kuljum", + "Period": "Perjodu", + "{day} period {number}": "{day} perjodu {number}", + "Teacher (user name)": "Għalliem (isem tal-utent)", + "Activity (group:subject)": "Attività (grupp:suġġett)", + "Say who or what the wish is about.": "Għid dwar min jew dwar xiex hi x-xewqa.", + "Choose at least one period.": "Agħżel mill-inqas perjodu wieħed.", + "The limit is a whole number of 1 or more.": "Il-limitu huwa numru sħiħ ta' 1 jew aktar.", + "Timetable": "Orarju", + "Wishes": "Xewqat", + "Timetable wishes": "Xewqat għall-orarju" }, "plurals": {} } diff --git a/l10n/nb.js b/l10n/nb.js index 6a42a851..ba08420d 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Årsak", "Why the run failed": "Hvorfor kjøringen mislyktes", "Published at": "Publisert", - "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer" + "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer", + "Edit wish": "Rediger ønske", + "New wish": "Nytt ønske", + "Hard: the generator always keeps it": "Hardt: generatoren følger det alltid", + "Soft: the generator keeps it where it can": "Mykt: generatoren følger det der den kan", + "Weight (1 to 3)": "Vekt (1 til 3)", + "Most lessons a day": "Høyst antall timer per dag", + "Period": "Time", + "{day} period {number}": "{day} time {number}", + "Teacher (user name)": "Lærer (brukernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Oppgi hvem eller hva ønsket gjelder.", + "Choose at least one period.": "Velg minst én time.", + "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", + "Timetable": "Timeplan", + "Wishes": "Ønsker", + "Timetable wishes": "Timeplanønsker" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 41ee8836..6315a70d 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1007,7 +1007,23 @@ "Reason": "Årsak", "Why the run failed": "Hvorfor kjøringen mislyktes", "Published at": "Publisert", - "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer" + "When the scenario was written out as draft lessons": "Når scenarioet ble skrevet ut som utkasttimer", + "Edit wish": "Rediger ønske", + "New wish": "Nytt ønske", + "Hard: the generator always keeps it": "Hardt: generatoren følger det alltid", + "Soft: the generator keeps it where it can": "Mykt: generatoren følger det der den kan", + "Weight (1 to 3)": "Vekt (1 til 3)", + "Most lessons a day": "Høyst antall timer per dag", + "Period": "Time", + "{day} period {number}": "{day} time {number}", + "Teacher (user name)": "Lærer (brukernavn)", + "Activity (group:subject)": "Aktivitet (gruppe:fag)", + "Say who or what the wish is about.": "Oppgi hvem eller hva ønsket gjelder.", + "Choose at least one period.": "Velg minst én time.", + "The limit is a whole number of 1 or more.": "Grensen er et heltall på 1 eller mer.", + "Timetable": "Timeplan", + "Wishes": "Ønsker", + "Timetable wishes": "Timeplanønsker" }, "plurals": {} } diff --git a/l10n/nl.js b/l10n/nl.js index 0b0655df..5b104625 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1021,7 +1021,23 @@ OC.L10N.register( "Reason": "Reden", "Why the run failed": "Waarom de run mislukte", "Published at": "Gepubliceerd op", - "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven" + "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven", + "Edit wish": "Wens bewerken", + "New wish": "Nieuwe wens", + "Hard: the generator always keeps it": "Hard: de generator houdt zich er altijd aan", + "Soft: the generator keeps it where it can": "Zacht: de generator houdt zich eraan waar het kan", + "Weight (1 to 3)": "Gewicht (1 tot 3)", + "Most lessons a day": "Hoogstens aantal lessen per dag", + "Period": "Lesuur", + "{day} period {number}": "{day} lesuur {number}", + "Teacher (user name)": "Docent (gebruikersnaam)", + "Activity (group:subject)": "Activiteit (groep:vak)", + "Say who or what the wish is about.": "Geef aan over wie of wat de wens gaat.", + "Choose at least one period.": "Kies minstens één lesuur.", + "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", + "Timetable": "Rooster", + "Wishes": "Wensen", + "Timetable wishes": "Roosterwensen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index e9849877..188ed3c1 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1020,7 +1020,23 @@ "Reason": "Reden", "Why the run failed": "Waarom de run mislukte", "Published at": "Gepubliceerd op", - "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven" + "When the scenario was written out as draft lessons": "Wanneer het scenario als conceptlessen is weggeschreven", + "Edit wish": "Wens bewerken", + "New wish": "Nieuwe wens", + "Hard: the generator always keeps it": "Hard: de generator houdt zich er altijd aan", + "Soft: the generator keeps it where it can": "Zacht: de generator houdt zich eraan waar het kan", + "Weight (1 to 3)": "Gewicht (1 tot 3)", + "Most lessons a day": "Hoogstens aantal lessen per dag", + "Period": "Lesuur", + "{day} period {number}": "{day} lesuur {number}", + "Teacher (user name)": "Docent (gebruikersnaam)", + "Activity (group:subject)": "Activiteit (groep:vak)", + "Say who or what the wish is about.": "Geef aan over wie of wat de wens gaat.", + "Choose at least one period.": "Kies minstens één lesuur.", + "The limit is a whole number of 1 or more.": "De limiet is een heel getal van 1 of meer.", + "Timetable": "Rooster", + "Wishes": "Wensen", + "Timetable wishes": "Roosterwensen" }, "plurals": {}, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index 757cb92b..20d86374 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Powód", "Why the run failed": "Dlaczego przebieg się nie udał", "Published at": "Opublikowano", - "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze" + "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze", + "Edit wish": "Edytuj życzenie", + "New wish": "Nowe życzenie", + "Hard: the generator always keeps it": "Twarde: generator zawsze go przestrzega", + "Soft: the generator keeps it where it can": "Miękkie: generator przestrzega go, gdy może", + "Weight (1 to 3)": "Waga (od 1 do 3)", + "Most lessons a day": "Najwyżej lekcji dziennie", + "Period": "Lekcja", + "{day} period {number}": "{day} lekcja {number}", + "Teacher (user name)": "Nauczyciel (nazwa użytkownika)", + "Activity (group:subject)": "Zajęcia (grupa:przedmiot)", + "Say who or what the wish is about.": "Podaj, kogo lub czego dotyczy życzenie.", + "Choose at least one period.": "Wybierz co najmniej jedną lekcję.", + "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", + "Timetable": "Plan lekcji", + "Wishes": "Życzenia", + "Timetable wishes": "Życzenia do planu lekcji" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index e6099d88..b3f28009 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1007,7 +1007,23 @@ "Reason": "Powód", "Why the run failed": "Dlaczego przebieg się nie udał", "Published at": "Opublikowano", - "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze" + "When the scenario was written out as draft lessons": "Kiedy scenariusz zapisano jako lekcje robocze", + "Edit wish": "Edytuj życzenie", + "New wish": "Nowe życzenie", + "Hard: the generator always keeps it": "Twarde: generator zawsze go przestrzega", + "Soft: the generator keeps it where it can": "Miękkie: generator przestrzega go, gdy może", + "Weight (1 to 3)": "Waga (od 1 do 3)", + "Most lessons a day": "Najwyżej lekcji dziennie", + "Period": "Lekcja", + "{day} period {number}": "{day} lekcja {number}", + "Teacher (user name)": "Nauczyciel (nazwa użytkownika)", + "Activity (group:subject)": "Zajęcia (grupa:przedmiot)", + "Say who or what the wish is about.": "Podaj, kogo lub czego dotyczy życzenie.", + "Choose at least one period.": "Wybierz co najmniej jedną lekcję.", + "The limit is a whole number of 1 or more.": "Limit to liczba całkowita równa 1 lub większa.", + "Timetable": "Plan lekcji", + "Wishes": "Życzenia", + "Timetable wishes": "Życzenia do planu lekcji" }, "plurals": {} } diff --git a/l10n/pt.js b/l10n/pt.js index fc500900..5d038eaf 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motivo", "Why the run failed": "Porque falhou a execução", "Published at": "Publicado em", - "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho" + "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho", + "Edit wish": "Editar desejo", + "New wish": "Novo desejo", + "Hard: the generator always keeps it": "Rígido: o gerador cumpre-o sempre", + "Soft: the generator keeps it where it can": "Flexível: o gerador cumpre-o quando pode", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de aulas por dia", + "Period": "Tempo", + "{day} period {number}": "{day} tempo {number}", + "Teacher (user name)": "Professor (nome de utilizador)", + "Activity (group:subject)": "Atividade (grupo:disciplina)", + "Say who or what the wish is about.": "Indique a quem ou a quê se refere o desejo.", + "Choose at least one period.": "Escolha pelo menos um tempo.", + "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", + "Timetable": "Horário", + "Wishes": "Desejos", + "Timetable wishes": "Desejos de horário" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 1d2ff8bc..d78a0e90 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1007,7 +1007,23 @@ "Reason": "Motivo", "Why the run failed": "Porque falhou a execução", "Published at": "Publicado em", - "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho" + "When the scenario was written out as draft lessons": "Quando o cenário foi escrito como aulas rascunho", + "Edit wish": "Editar desejo", + "New wish": "Novo desejo", + "Hard: the generator always keeps it": "Rígido: o gerador cumpre-o sempre", + "Soft: the generator keeps it where it can": "Flexível: o gerador cumpre-o quando pode", + "Weight (1 to 3)": "Peso (1 a 3)", + "Most lessons a day": "Máximo de aulas por dia", + "Period": "Tempo", + "{day} period {number}": "{day} tempo {number}", + "Teacher (user name)": "Professor (nome de utilizador)", + "Activity (group:subject)": "Atividade (grupo:disciplina)", + "Say who or what the wish is about.": "Indique a quem ou a quê se refere o desejo.", + "Choose at least one period.": "Escolha pelo menos um tempo.", + "The limit is a whole number of 1 or more.": "O limite é um número inteiro de 1 ou mais.", + "Timetable": "Horário", + "Wishes": "Desejos", + "Timetable wishes": "Desejos de horário" }, "plurals": {} } diff --git a/l10n/rm.js b/l10n/rm.js index 891db5ad..39114072 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiv", "Why the run failed": "Pertge ch'il currer è fallì", "Published at": "Publitgà ils", - "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz" + "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz", + "Edit wish": "Modifitgar il giavisch", + "New wish": "Nov giavisch", + "Hard: the generator always keeps it": "Obligatoric: il generatur al resguarda adina", + "Soft: the generator keeps it where it can": "Flexibel: il generatur al resguarda nua ch'el po", + "Weight (1 to 3)": "Paisa (1 fin 3)", + "Most lessons a day": "Il pli bler lecziuns per di", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Magister (num d'utilisader)", + "Activity (group:subject)": "Activitad (gruppa:rom)", + "Say who or what the wish is about.": "Inditgai per tgi u tge ch'il giavisch vala.", + "Choose at least one period.": "Tscherni almain ina ura.", + "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", + "Timetable": "Plan d'uras", + "Wishes": "Giavischs", + "Timetable wishes": "Giavischs per il plan d'uras" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 92522b85..6f85826f 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1007,7 +1007,23 @@ "Reason": "Motiv", "Why the run failed": "Pertge ch'il currer è fallì", "Published at": "Publitgà ils", - "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz" + "When the scenario was written out as draft lessons": "Cura ch'il scenari è vegnì scrit sco lecziuns sbozz", + "Edit wish": "Modifitgar il giavisch", + "New wish": "Nov giavisch", + "Hard: the generator always keeps it": "Obligatoric: il generatur al resguarda adina", + "Soft: the generator keeps it where it can": "Flexibel: il generatur al resguarda nua ch'el po", + "Weight (1 to 3)": "Paisa (1 fin 3)", + "Most lessons a day": "Il pli bler lecziuns per di", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Magister (num d'utilisader)", + "Activity (group:subject)": "Activitad (gruppa:rom)", + "Say who or what the wish is about.": "Inditgai per tgi u tge ch'il giavisch vala.", + "Choose at least one period.": "Tscherni almain ina ura.", + "The limit is a whole number of 1 or more.": "La limita è in dumber entir da 1 u dapli.", + "Timetable": "Plan d'uras", + "Wishes": "Giavischs", + "Timetable wishes": "Giavischs per il plan d'uras" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 750b55dc..8fdfc0f5 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Motiv", "Why the run failed": "De ce a eșuat rularea", "Published at": "Publicat la", - "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă" + "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă", + "Edit wish": "Editează dorința", + "New wish": "Dorință nouă", + "Hard: the generator always keeps it": "Strictă: generatorul o respectă mereu", + "Soft: the generator keeps it where it can": "Flexibilă: generatorul o respectă unde poate", + "Weight (1 to 3)": "Pondere (de la 1 la 3)", + "Most lessons a day": "Cel mult ore pe zi", + "Period": "Oră", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Profesor (nume de utilizator)", + "Activity (group:subject)": "Activitate (grupă:materie)", + "Say who or what the wish is about.": "Indicați la cine sau la ce se referă dorința.", + "Choose at least one period.": "Alegeți cel puțin o oră.", + "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", + "Timetable": "Orar", + "Wishes": "Dorințe", + "Timetable wishes": "Dorințe pentru orar" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index a7e47901..47c184c1 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1007,7 +1007,23 @@ "Reason": "Motiv", "Why the run failed": "De ce a eșuat rularea", "Published at": "Publicat la", - "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă" + "When the scenario was written out as draft lessons": "Când a fost scris scenariul ca ore ciornă", + "Edit wish": "Editează dorința", + "New wish": "Dorință nouă", + "Hard: the generator always keeps it": "Strictă: generatorul o respectă mereu", + "Soft: the generator keeps it where it can": "Flexibilă: generatorul o respectă unde poate", + "Weight (1 to 3)": "Pondere (de la 1 la 3)", + "Most lessons a day": "Cel mult ore pe zi", + "Period": "Oră", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Profesor (nume de utilizator)", + "Activity (group:subject)": "Activitate (grupă:materie)", + "Say who or what the wish is about.": "Indicați la cine sau la ce se referă dorința.", + "Choose at least one period.": "Alegeți cel puțin o oră.", + "The limit is a whole number of 1 or more.": "Limita este un număr întreg de cel puțin 1.", + "Timetable": "Orar", + "Wishes": "Dorințe", + "Timetable wishes": "Dorințe pentru orar" }, "plurals": {} } diff --git a/l10n/ru.js b/l10n/ru.js index b81511ea..7df38c78 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Почему запуск не удался", "Published at": "Опубликован", - "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки" + "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки", + "Edit wish": "Изменить пожелание", + "New wish": "Новое пожелание", + "Hard: the generator always keeps it": "Обязательное: генератор всегда его соблюдает", + "Soft: the generator keeps it where it can": "Гибкое: генератор соблюдает его, где может", + "Weight (1 to 3)": "Вес (от 1 до 3)", + "Most lessons a day": "Не больше уроков в день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (имя пользователя)", + "Activity (group:subject)": "Занятие (группа:предмет)", + "Say who or what the wish is about.": "Укажите, кого или что касается пожелание.", + "Choose at least one period.": "Выберите хотя бы один урок.", + "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", + "Timetable": "Расписание", + "Wishes": "Пожелания", + "Timetable wishes": "Пожелания к расписанию" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index eb715f66..8ee034a6 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Почему запуск не удался", "Published at": "Опубликован", - "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки" + "When the scenario was written out as draft lessons": "Когда сценарий записан как черновые уроки", + "Edit wish": "Изменить пожелание", + "New wish": "Новое пожелание", + "Hard: the generator always keeps it": "Обязательное: генератор всегда его соблюдает", + "Soft: the generator keeps it where it can": "Гибкое: генератор соблюдает его, где может", + "Weight (1 to 3)": "Вес (от 1 до 3)", + "Most lessons a day": "Не больше уроков в день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (имя пользователя)", + "Activity (group:subject)": "Занятие (группа:предмет)", + "Say who or what the wish is about.": "Укажите, кого или что касается пожелание.", + "Choose at least one period.": "Выберите хотя бы один урок.", + "The limit is a whole number of 1 or more.": "Лимит: целое число от 1.", + "Timetable": "Расписание", + "Wishes": "Пожелания", + "Timetable wishes": "Пожелания к расписанию" }, "plurals": {} } diff --git a/l10n/sk.js b/l10n/sk.js index ccd9de81..9f055f7d 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Dôvod", "Why the run failed": "Prečo beh zlyhal", "Published at": "Zverejnené", - "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín" + "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín", + "Edit wish": "Upraviť želanie", + "New wish": "Nové želanie", + "Hard: the generator always keeps it": "Pevné: generátor ho vždy dodrží", + "Soft: the generator keeps it where it can": "Mäkké: generátor ho dodrží, kde sa dá", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Najviac hodín denne", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učiteľ (používateľské meno)", + "Activity (group:subject)": "Aktivita (skupina:predmet)", + "Say who or what the wish is about.": "Uveďte, koho alebo čoho sa želanie týka.", + "Choose at least one period.": "Vyberte aspoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", + "Timetable": "Rozvrh", + "Wishes": "Želania", + "Timetable wishes": "Želania k rozvrhu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index c1e92be7..f461d2a9 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1007,7 +1007,23 @@ "Reason": "Dôvod", "Why the run failed": "Prečo beh zlyhal", "Published at": "Zverejnené", - "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín" + "When the scenario was written out as draft lessons": "Kedy bol scenár zapísaný ako koncepty hodín", + "Edit wish": "Upraviť želanie", + "New wish": "Nové želanie", + "Hard: the generator always keeps it": "Pevné: generátor ho vždy dodrží", + "Soft: the generator keeps it where it can": "Mäkké: generátor ho dodrží, kde sa dá", + "Weight (1 to 3)": "Váha (1 až 3)", + "Most lessons a day": "Najviac hodín denne", + "Period": "Hodina", + "{day} period {number}": "{day} hodina {number}", + "Teacher (user name)": "Učiteľ (používateľské meno)", + "Activity (group:subject)": "Aktivita (skupina:predmet)", + "Say who or what the wish is about.": "Uveďte, koho alebo čoho sa želanie týka.", + "Choose at least one period.": "Vyberte aspoň jednu hodinu.", + "The limit is a whole number of 1 or more.": "Limit je celé číslo 1 alebo viac.", + "Timetable": "Rozvrh", + "Wishes": "Želania", + "Timetable wishes": "Želania k rozvrhu" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index 39b463b8..a454a528 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zakaj zagon ni uspel", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur" + "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur", + "Edit wish": "Uredi željo", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obvezna: generator jo vedno upošteva", + "Soft: the generator keeps it where it can": "Prožna: generator jo upošteva, kjer lahko", + "Weight (1 to 3)": "Utež (od 1 do 3)", + "Most lessons a day": "Največ ur na dan", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Učitelj (uporabniško ime)", + "Activity (group:subject)": "Dejavnost (skupina:predmet)", + "Say who or what the wish is about.": "Navedite, na koga ali kaj se želja nanaša.", + "Choose at least one period.": "Izberite vsaj eno uro.", + "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", + "Timetable": "Urnik", + "Wishes": "Želje", + "Timetable wishes": "Želje za urnik" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index ad8b8f79..437c3f13 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zakaj zagon ni uspel", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur" + "When the scenario was written out as draft lessons": "Kdaj je bil scenarij zapisan kot osnutki ur", + "Edit wish": "Uredi željo", + "New wish": "Nova želja", + "Hard: the generator always keeps it": "Obvezna: generator jo vedno upošteva", + "Soft: the generator keeps it where it can": "Prožna: generator jo upošteva, kjer lahko", + "Weight (1 to 3)": "Utež (od 1 do 3)", + "Most lessons a day": "Največ ur na dan", + "Period": "Ura", + "{day} period {number}": "{day} ura {number}", + "Teacher (user name)": "Učitelj (uporabniško ime)", + "Activity (group:subject)": "Dejavnost (skupina:predmet)", + "Say who or what the wish is about.": "Navedite, na koga ali kaj se želja nanaša.", + "Choose at least one period.": "Izberite vsaj eno uro.", + "The limit is a whole number of 1 or more.": "Omejitev je celo število 1 ali več.", + "Timetable": "Urnik", + "Wishes": "Želje", + "Timetable wishes": "Želje za urnik" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index 3c43b018..90d32143 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Arsyeja", "Why the run failed": "Pse dështoi ekzekutimi", "Published at": "Publikuar më", - "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft" + "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft", + "Edit wish": "Ndrysho dëshirën", + "New wish": "Dëshirë e re", + "Hard: the generator always keeps it": "E detyrueshme: gjeneratori e respekton gjithmonë", + "Soft: the generator keeps it where it can": "E butë: gjeneratori e respekton kur mundet", + "Weight (1 to 3)": "Pesha (nga 1 deri në 3)", + "Most lessons a day": "Së shumti orë mësimi në ditë", + "Period": "Orë", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Mësues (emri i përdoruesit)", + "Activity (group:subject)": "Aktivitet (grup:lëndë)", + "Say who or what the wish is about.": "Tregoni për kë ose për çfarë është dëshira.", + "Choose at least one period.": "Zgjidhni të paktën një orë.", + "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", + "Timetable": "Orari", + "Wishes": "Dëshirat", + "Timetable wishes": "Dëshirat për orarin" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bf1d2e14..140814cb 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1007,7 +1007,23 @@ "Reason": "Arsyeja", "Why the run failed": "Pse dështoi ekzekutimi", "Published at": "Publikuar më", - "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft" + "When the scenario was written out as draft lessons": "Kur u shkrua skenari si orë draft", + "Edit wish": "Ndrysho dëshirën", + "New wish": "Dëshirë e re", + "Hard: the generator always keeps it": "E detyrueshme: gjeneratori e respekton gjithmonë", + "Soft: the generator keeps it where it can": "E butë: gjeneratori e respekton kur mundet", + "Weight (1 to 3)": "Pesha (nga 1 deri në 3)", + "Most lessons a day": "Së shumti orë mësimi në ditë", + "Period": "Orë", + "{day} period {number}": "{day} ora {number}", + "Teacher (user name)": "Mësues (emri i përdoruesit)", + "Activity (group:subject)": "Aktivitet (grup:lëndë)", + "Say who or what the wish is about.": "Tregoni për kë ose për çfarë është dëshira.", + "Choose at least one period.": "Zgjidhni të paktën një orë.", + "The limit is a whole number of 1 or more.": "Kufiri është numër i plotë 1 ose më shumë.", + "Timetable": "Orari", + "Wishes": "Dëshirat", + "Timetable wishes": "Dëshirat për orarin" }, "plurals": {} } diff --git a/l10n/sr.js b/l10n/sr.js index befb312d..d2a0cc59 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova", + "Edit wish": "Уреди жељу", + "New wish": "Нова жеља", + "Hard: the generator always keeps it": "Обавезна: генератор је увек поштује", + "Soft: the generator keeps it where it can": "Флексибилна: генератор је поштује где може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Највише часова дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведите на кога или шта се жеља односи.", + "Choose at least one period.": "Изаберите бар један час.", + "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", + "Timetable": "Распоред", + "Wishes": "Жеље", + "Timetable wishes": "Жеље за распоред" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 4724cf92..a66d5cbb 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1007,7 +1007,23 @@ "Reason": "Razlog", "Why the run failed": "Zašto pokretanje nije uspelo", "Published at": "Objavljeno", - "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova" + "When the scenario was written out as draft lessons": "Kada je scenario zapisan kao nacrti časova", + "Edit wish": "Уреди жељу", + "New wish": "Нова жеља", + "Hard: the generator always keeps it": "Обавезна: генератор је увек поштује", + "Soft: the generator keeps it where it can": "Флексибилна: генератор је поштује где може", + "Weight (1 to 3)": "Тежина (од 1 до 3)", + "Most lessons a day": "Највише часова дневно", + "Period": "Час", + "{day} period {number}": "{day} час {number}", + "Teacher (user name)": "Наставник (корисничко име)", + "Activity (group:subject)": "Активност (група:предмет)", + "Say who or what the wish is about.": "Наведите на кога или шта се жеља односи.", + "Choose at least one period.": "Изаберите бар један час.", + "The limit is a whole number of 1 or more.": "Ограничење је цео број 1 или више.", + "Timetable": "Распоред", + "Wishes": "Жеље", + "Timetable wishes": "Жеље за распоред" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index e62e5a2a..4c4d112d 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Orsak", "Why the run failed": "Varför körningen misslyckades", "Published at": "Publicerat", - "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner" + "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner", + "Edit wish": "Redigera önskemål", + "New wish": "Nytt önskemål", + "Hard: the generator always keeps it": "Hårt: generatorn följer det alltid", + "Soft: the generator keeps it where it can": "Mjukt: generatorn följer det där den kan", + "Weight (1 to 3)": "Vikt (1 till 3)", + "Most lessons a day": "Högst antal lektioner per dag", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lärare (användarnamn)", + "Activity (group:subject)": "Aktivitet (grupp:ämne)", + "Say who or what the wish is about.": "Ange vem eller vad önskemålet gäller.", + "Choose at least one period.": "Välj minst en lektion.", + "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", + "Timetable": "Schema", + "Wishes": "Önskemål", + "Timetable wishes": "Schemaönskemål" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index adfdc18d..80ca608a 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1007,7 +1007,23 @@ "Reason": "Orsak", "Why the run failed": "Varför körningen misslyckades", "Published at": "Publicerat", - "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner" + "When the scenario was written out as draft lessons": "När scenariot skrevs ut som utkastlektioner", + "Edit wish": "Redigera önskemål", + "New wish": "Nytt önskemål", + "Hard: the generator always keeps it": "Hårt: generatorn följer det alltid", + "Soft: the generator keeps it where it can": "Mjukt: generatorn följer det där den kan", + "Weight (1 to 3)": "Vikt (1 till 3)", + "Most lessons a day": "Högst antal lektioner per dag", + "Period": "Lektion", + "{day} period {number}": "{day} lektion {number}", + "Teacher (user name)": "Lärare (användarnamn)", + "Activity (group:subject)": "Aktivitet (grupp:ämne)", + "Say who or what the wish is about.": "Ange vem eller vad önskemålet gäller.", + "Choose at least one period.": "Välj minst en lektion.", + "The limit is a whole number of 1 or more.": "Gränsen är ett heltal på 1 eller mer.", + "Timetable": "Schema", + "Wishes": "Önskemål", + "Timetable wishes": "Schemaönskemål" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 471d8f14..552719b9 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Neden", "Why the run failed": "Çalıştırmanın neden başarısız olduğu", "Published at": "Yayımlanma zamanı", - "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı" + "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı", + "Edit wish": "Dileği düzenle", + "New wish": "Yeni dilek", + "Hard: the generator always keeps it": "Kesin: oluşturucu her zaman uyar", + "Soft: the generator keeps it where it can": "Esnek: oluşturucu elinden geldiğince uyar", + "Weight (1 to 3)": "Ağırlık (1 ile 3 arası)", + "Most lessons a day": "Günde en fazla ders", + "Period": "Ders saati", + "{day} period {number}": "{day} {number}. ders saati", + "Teacher (user name)": "Öğretmen (kullanıcı adı)", + "Activity (group:subject)": "Etkinlik (grup:ders)", + "Say who or what the wish is about.": "Dileğin kimi veya neyi kapsadığını belirtin.", + "Choose at least one period.": "En az bir ders saati seçin.", + "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", + "Timetable": "Ders programı", + "Wishes": "Dilekler", + "Timetable wishes": "Ders programı dilekleri" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index ebdca64a..2f55dbf2 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1007,7 +1007,23 @@ "Reason": "Neden", "Why the run failed": "Çalıştırmanın neden başarısız olduğu", "Published at": "Yayımlanma zamanı", - "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı" + "When the scenario was written out as draft lessons": "Senaryonun taslak ders olarak ne zaman yazıldığı", + "Edit wish": "Dileği düzenle", + "New wish": "Yeni dilek", + "Hard: the generator always keeps it": "Kesin: oluşturucu her zaman uyar", + "Soft: the generator keeps it where it can": "Esnek: oluşturucu elinden geldiğince uyar", + "Weight (1 to 3)": "Ağırlık (1 ile 3 arası)", + "Most lessons a day": "Günde en fazla ders", + "Period": "Ders saati", + "{day} period {number}": "{day} {number}. ders saati", + "Teacher (user name)": "Öğretmen (kullanıcı adı)", + "Activity (group:subject)": "Etkinlik (grup:ders)", + "Say who or what the wish is about.": "Dileğin kimi veya neyi kapsadığını belirtin.", + "Choose at least one period.": "En az bir ders saati seçin.", + "The limit is a whole number of 1 or more.": "Sınır 1 veya daha büyük bir tam sayıdır.", + "Timetable": "Ders programı", + "Wishes": "Dilekler", + "Timetable wishes": "Ders programı dilekleri" }, "plurals": {} } diff --git a/l10n/uk.js b/l10n/uk.js index 7f30c879..f930a322 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1008,7 +1008,23 @@ OC.L10N.register( "Reason": "Причина", "Why the run failed": "Чому запуск не вдався", "Published at": "Опубліковано", - "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки" + "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки", + "Edit wish": "Змінити побажання", + "New wish": "Нове побажання", + "Hard: the generator always keeps it": "Обов'язкове: генератор завжди його дотримується", + "Soft: the generator keeps it where it can": "Гнучке: генератор дотримується його, де може", + "Weight (1 to 3)": "Вага (від 1 до 3)", + "Most lessons a day": "Не більше уроків на день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (ім'я користувача)", + "Activity (group:subject)": "Заняття (група:предмет)", + "Say who or what the wish is about.": "Вкажіть, кого чи чого стосується побажання.", + "Choose at least one period.": "Виберіть хоча б один урок.", + "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", + "Timetable": "Розклад", + "Wishes": "Побажання", + "Timetable wishes": "Побажання до розкладу" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 91f24b07..713e5c03 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1007,7 +1007,23 @@ "Reason": "Причина", "Why the run failed": "Чому запуск не вдався", "Published at": "Опубліковано", - "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки" + "When the scenario was written out as draft lessons": "Коли сценарій записано як чернеткові уроки", + "Edit wish": "Змінити побажання", + "New wish": "Нове побажання", + "Hard: the generator always keeps it": "Обов'язкове: генератор завжди його дотримується", + "Soft: the generator keeps it where it can": "Гнучке: генератор дотримується його, де може", + "Weight (1 to 3)": "Вага (від 1 до 3)", + "Most lessons a day": "Не більше уроків на день", + "Period": "Урок", + "{day} period {number}": "{day} урок {number}", + "Teacher (user name)": "Учитель (ім'я користувача)", + "Activity (group:subject)": "Заняття (група:предмет)", + "Say who or what the wish is about.": "Вкажіть, кого чи чого стосується побажання.", + "Choose at least one period.": "Виберіть хоча б один урок.", + "The limit is a whole number of 1 or more.": "Ліміт: ціле число від 1.", + "Timetable": "Розклад", + "Wishes": "Побажання", + "Timetable wishes": "Побажання до розкладу" }, "plurals": {} } diff --git a/lib/Controller/TimetableInputController.php b/lib/Controller/TimetableInputController.php new file mode 100644 index 00000000..cff85fe1 --- /dev/null +++ b/lib/Controller/TimetableInputController.php @@ -0,0 +1,109 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Controller; + +use OCA\Planninq\AppInfo\Application; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Takes the uploaded rooms and activities sheets. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ +class TimetableInputController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param TimetableCsvParser $parser Reads the sheets. + * @param TimetableInputBuilder $inputBuilder Keeps the parsed sheets. + * @param SettingsService $settingsService Answers whether the caller is an admin. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly TimetableCsvParser $parser, + private readonly TimetableInputBuilder $inputBuilder, + private readonly SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Parse and keep the rooms and activities sheets. Admins only. + * + * Nextcloud's admin middleware refuses everyone else before this runs; the + * explicit check is defence in depth, as in TimetableController::upsert(). + * Nothing is kept when either sheet has a refused line. + * + * @return JSONResponse 200 with the counts; 400 with the refused lines; 403 for a non-admin. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + #[AuthorizedAdminSetting(settings: AdminSettings::class)] + public function upload(): JSONResponse { + if ($this->settingsService->isCurrentUserAdmin() === false) { + return new JSONResponse(['error' => 'Only an admin can upload timetable activities.'], Http::STATUS_FORBIDDEN); + } + + $roomsCsv = $this->request->getParam('rooms'); + $activitiesCsv = $this->request->getParam('activities'); + if (is_string($roomsCsv) === false || is_string($activitiesCsv) === false) { + return new JSONResponse(['error' => 'Send the rooms and the activities sheet as text.'], Http::STATUS_BAD_REQUEST); + } + + $rooms = $this->parser->parseRooms(csv: $roomsCsv); + $types = array_values(array_unique(array_column($rooms['rows'], 'type'))); + $activities = $this->parser->parseActivities(csv: $activitiesCsv, roomTypes: $types); + $errors = [ + 'rooms' => $rooms['errors'], + 'activities' => $activities['errors'], + ]; + if ($rooms['errors'] !== [] || $activities['errors'] !== []) { + return new JSONResponse(['error' => 'Some lines were refused. Nothing was kept.', 'errors' => $errors], Http::STATUS_BAD_REQUEST); + } + + $this->inputBuilder->storeUpload(rooms: $rooms['rows'], activities: $activities['rows']); + + return new JSONResponse( + [ + 'rooms' => count($rooms['rows']), + 'activities' => count($activities['rows']), + 'lessons' => array_sum(array_column($activities['rows'], 'lessonsPerWeek')), + ] + ); + }//end upload() +}//end class diff --git a/lib/Event/TimetableActivitiesQueryEvent.php b/lib/Event/TimetableActivitiesQueryEvent.php new file mode 100644 index 00000000..6dac14cc --- /dev/null +++ b/lib/Event/TimetableActivitiesQueryEvent.php @@ -0,0 +1,148 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Event; + +use OCP\EventDispatcher\Event; + +/** + * A request for the activities and rooms of one academic year. + * + * An activity row: group, subject, teacher (a Nextcloud user id), lessonsPerWeek, + * lessonLength (in periods) and roomType. A room row: reference, capacity, type. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ +class TimetableActivitiesQueryEvent extends Event { + + /** + * Contract version of this event and its answer. + * + * @var integer + */ + public const CONTRACT_VERSION = 1; + + /** + * The activities, once an app answered. + * + * @var array>|null + */ + private ?array $activities = null; + + /** + * The rooms, once an app answered. + * + * @var array> + */ + private array $rooms = []; + + /** + * The app that answered. + * + * @var string|null + */ + private ?string $answeredBy = null; + + /** + * Constructor. + * + * @param string $academicYear The academic year asked for, such as `2026-2027`. + * + * @return void + */ + public function __construct( + private readonly string $academicYear, + ) { + parent::__construct(); + }//end __construct() + + /** + * The academic year asked for. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAcademicYear(): string { + return $this->academicYear; + }//end getAcademicYear() + + /** + * Answer the event with the year's activities and rooms. + * + * @param string $app The answering app, such as `learniq`. + * @param array> $activities Activity rows. + * @param array> $rooms Room rows. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function answer(string $app, array $activities, array $rooms): void { + $this->answeredBy = $app; + $this->activities = array_values($activities); + $this->rooms = array_values($rooms); + }//end answer() + + /** + * The activities, or null when no app answered. + * + * @return array>|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getActivities(): ?array { + return $this->activities; + }//end getActivities() + + /** + * The rooms the answering app sent. + * + * @return array> + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getRooms(): array { + return $this->rooms; + }//end getRooms() + + /** + * The app that answered, or null. + * + * @return string|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function getAnsweredBy(): ?string { + return $this->answeredBy; + }//end getAnsweredBy() +}//end class diff --git a/lib/Service/TimetableCsvParser.php b/lib/Service/TimetableCsvParser.php new file mode 100644 index 00000000..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/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/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/TimetableScorer.php b/lib/Timetabling/TimetableScorer.php new file mode 100644 index 00000000..29e47500 --- /dev/null +++ b/lib/Timetabling/TimetableScorer.php @@ -0,0 +1,384 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Scores placements (lesson key, first period key, room reference) against a SolverInput. + * + * Clash kinds: `teacher`, `group` and `room` (two lessons in one period), + * `roomType` (a room of another type than the lesson needs), `unknownRoom` + * and `outOfGrid` (a lesson that starts outside the grid or runs off the day). + * A broken soft wish costs its weight (1 when unset) for every lesson that breaks it. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class TimetableScorer { + + /** + * Cost of one clash; above everything else, so no clash is traded for a wish. + */ + public const CLASH_COST = 1000000; + + /** + * Cost of one broken hard wish. + */ + public const HARD_COST = 100000; + + /** + * Cost of one lesson that is not placed. + */ + public const UNPLACED_COST = 10000; + + /** + * Cost of one point of soft penalty. + */ + public const SOFT_COST = 10; + + /** + * Cost of one free period between a teacher's lessons. + */ + public const GAP_COST = 1; + + /** + * Constructor. + * + * @param WishChecker $wishes Evaluates the wishes. + * + * @return void + */ + public function __construct( + private readonly WishChecker $wishes=new WishChecker(), + ) { + }//end __construct() + + /** + * The score of a week. + * + * @param SolverInput $input What was to be placed. + * @param array $placements Where the lessons are. + * + * @return array{metrics:array,brokenWishes:array>,clashes:array>} + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function score(SolverInput $input, array $placements): array { + $grid = new WeekGrid(periods: $input->periods); + $clashes = []; + $slots = $this->slots(input: $input, placements: $placements, grid: $grid, clashes: $clashes); + $clashes = array_merge($clashes, $this->doubleBookings(slots: $slots)); + $broken = $this->brokenWishes(wishes: $input->wishes, slots: $slots, grid: $grid); + + $metrics = array_merge( + [ + 'lessons' => count($input->lessons), + 'placed' => count($slots), + 'unplaced' => (count($input->lessons) - count($slots)), + 'clashes' => count($clashes), + ], + $this->wishMetrics(broken: $broken), + $this->teacherGaps(slots: $slots, grid: $grid), + ['lessonsPerDayWorst' => $this->lessonsPerDayWorst(slots: $slots)], + $this->roomUse(input: $input, slots: $slots) + ); + + return ['metrics' => $metrics, 'brokenWishes' => $broken, 'clashes' => $clashes]; + }//end score() + + /** + * One number to minimise: clashes, then hard wishes, then unplaced lessons, then soft wishes, then gaps. + * + * @param array $metrics The metrics of score(). + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public static function cost(array $metrics): int { + return ((int)$metrics['clashes'] * self::CLASH_COST) + + ((int)$metrics['hardWishesBroken'] * self::HARD_COST) + + ((int)$metrics['unplaced'] * self::UNPLACED_COST) + + ((int)$metrics['softPenalty'] * self::SOFT_COST) + + ((int)$metrics['teacherGaps'] * self::GAP_COST); + }//end cost() + + /** + * The placed lessons with the periods they take; a placement that cannot be laid out is a clash. + * + * @param SolverInput $input The input. + * @param array> $placements The placements. + * @param WeekGrid $grid The grid. + * @param array> $clashes Collects the clashes. + * + * @return array> + */ + private function slots(SolverInput $input, array $placements, WeekGrid $grid, array &$clashes): array { + $lessons = array_column($input->lessons, null, 'key'); + $rooms = array_column($input->rooms, null, 'reference'); + $order = array_flip($grid->keys()); + $slots = []; + foreach ($placements as $placement) { + $lesson = ($lessons[(string)$placement['lesson']] ?? null); + if ($lesson === null) { + continue; + } + + $room = (string)$placement['room']; + $periods = $grid->span(start: (string)$placement['period'], length: (int)($lesson['length'] ?? 1)); + $problem = $this->placementProblem(lesson: $lesson, room: ($rooms[$room] ?? null), periods: $periods); + if ($problem !== null) { + $clashes[] = ['kind' => $problem, 'reference' => $room, 'period' => (string)$placement['period'], 'lessons' => [(string)$lesson['key']]]; + } + + if ($periods === null) { + continue; + } + + $slots[] = [ + 'lesson' => $lesson, + 'room' => $room, + 'periods' => $periods, + 'day' => $grid->day(key: $periods[0]), + 'order' => (int)$order[$periods[0]], + ]; + }//end foreach + + return $slots; + }//end slots() + + /** + * What is wrong with one placement by itself, or null. + * + * @param array $lesson The lesson. + * @param array|null $room The room, or null when unknown. + * @param array|null $periods The periods, or null when off the grid. + * + * @return string|null + */ + private function placementProblem(array $lesson, ?array $room, ?array $periods): ?string { + if ($periods === null) { + return 'outOfGrid'; + } + + if ($room === null) { + return 'unknownRoom'; + } + + $needed = (string)($lesson['roomType'] ?? ''); + if ($needed !== '' && (string)($room['type'] ?? '') !== $needed) { + return 'roomType'; + } + + return null; + }//end placementProblem() + + /** + * Two lessons of one teacher, group or room in the same period: one clash per resource and period. + * + * @param array> $slots The placed lessons. + * + * @return array> + */ + private function doubleBookings(array $slots): array { + $taken = []; + foreach ($slots as $slot) { + $owners = ['teacher' => (string)$slot['lesson']['teacher'], 'group' => (string)$slot['lesson']['group'], 'room' => (string)$slot['room']]; + foreach ($slot['periods'] as $period) { + foreach ($owners as $kind => $reference) { + $taken[$kind."\n".$reference."\n".$period][] = (string)$slot['lesson']['key']; + } + } + } + + $clashes = []; + foreach ($taken as $at => $lessons) { + if (count($lessons) > 1 && explode("\n", $at)[1] !== '') { + [$kind, $reference, $period] = explode("\n", $at); + $clashes[] = ['kind' => $kind, 'reference' => $reference, 'period' => $period, 'lessons' => $lessons]; + } + } + + return $clashes; + }//end doubleBookings() + + /** + * Every broken wish with the lessons that break it. + * + * @param array> $wishes The wishes. + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function brokenWishes(array $wishes, array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($wishes as $wish) { + $lessons = $this->wishes->breakingLessons(wish: $wish, slots: $slots, grid: $grid); + if ($lessons === []) { + continue; + } + + $hard = ($wish['strength'] ?? 'soft') === 'hard'; + $broken[] = [ + 'wish' => (string)($wish['id'] ?? ''), + 'strength' => (string)($wish['strength'] ?? 'soft'), + 'weight' => $this->weight(wish: $wish, hard: $hard), + 'lessons' => $lessons, + ]; + } + + return $broken; + }//end brokenWishes() + + /** + * The weight of a broken wish: null for a hard wish, else 1 to 3 (1 when unset). + * + * @param array $wish The wish. + * @param bool $hard Whether it is hard. + * + * @return int|null + */ + private function weight(array $wish, bool $hard): ?int { + if ($hard === true) { + return null; + } + + return max(1, (int)($wish['weight'] ?? 1)); + }//end weight() + + /** + * Hard and soft wishes broken, and the weighted soft penalty. + * + * @param array> $broken The broken wishes. + * + * @return array{hardWishesBroken:int,softWishesBroken:int,softPenalty:int} + */ + private function wishMetrics(array $broken): array { + $metrics = ['hardWishesBroken' => 0, 'softWishesBroken' => 0, 'softPenalty' => 0]; + foreach ($broken as $wish) { + if ($wish['strength'] === 'hard') { + $metrics['hardWishesBroken']++; + continue; + } + + $metrics['softWishesBroken']++; + $metrics['softPenalty'] += ((int)$wish['weight'] * count($wish['lessons'])); + } + + return $metrics; + }//end wishMetrics() + + /** + * Free periods between a teacher's lessons on a day: in total and for the teacher with the most. + * + * @param array> $slots The placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array{teacherGaps:int,teacherGapsWorst:int} + */ + private function teacherGaps(array $slots, WeekGrid $grid): array { + $numbers = []; + foreach ($slots as $slot) { + foreach ($slot['periods'] as $period) { + $numbers[(string)$slot['lesson']['teacher']][$grid->day(key: $period)][] = $grid->number(key: $period); + } + } + + $total = 0; + $worst = 0; + foreach ($numbers as $days) { + $gaps = array_sum(array_map([$grid, 'gaps'], $days)); + $total += $gaps; + $worst = max($worst, $gaps); + } + + return ['teacherGaps' => $total, 'teacherGapsWorst' => $worst]; + }//end teacherGaps() + + /** + * The most lessons one group has on one day. + * + * @param array> $slots The placed lessons. + * + * @return int + */ + private function lessonsPerDayWorst(array $slots): int { + $counts = []; + foreach ($slots as $slot) { + $key = (string)$slot['lesson']['group']."\n".(string)$slot['day']; + $counts[$key] = (($counts[$key] ?? 0) + 1); + } + + if ($counts === []) { + return 0; + } + + return (int)max($counts); + }//end lessonsPerDayWorst() + + /** + * The share of room periods in use, overall and per room type, rounded to two decimals. + * + * @param SolverInput $input The input. + * @param array> $slots The placed lessons. + * + * @return array{roomUse:float,roomUseByType:array} + */ + private function roomUse(SolverInput $input, array $slots): array { + $types = array_column($input->rooms, 'type', 'reference'); + $used = []; + foreach ($slots as $slot) { + $type = ($types[(string)$slot['room']] ?? null); + if ($type !== null) { + $used[(string)$type] = (($used[(string)$type] ?? 0) + count($slot['periods'])); + } + } + + $periods = count($input->periods); + $byType = []; + foreach (array_count_values(array_map('strval', array_values($types))) as $type => $rooms) { + $byType[(string)$type] = $this->share(used: ($used[$type] ?? 0), available: ($rooms * $periods)); + } + + return ['roomUse' => $this->share(used: array_sum($used), available: (count($types) * $periods)), 'roomUseByType' => $byType]; + }//end roomUse() + + /** + * A share rounded to two decimals, 0.0 when nothing is available. + * + * @param int $used Used. + * @param int $available Available. + * + * @return float + */ + private function share(int $used, int $available): float { + if ($available === 0) { + return 0.0; + } + + return round($used / $available, 2); + }//end share() +}//end class diff --git a/lib/Timetabling/WeekGrid.php b/lib/Timetabling/WeekGrid.php new file mode 100644 index 00000000..196044f6 --- /dev/null +++ b/lib/Timetabling/WeekGrid.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * Days and period numbers of a list of period keys such as `mon-3`. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WeekGrid { + + /** + * The period keys, as a set. + * + * @var array + */ + private array $known = []; + + /** + * Constructor. + * + * @param array $periods The period keys of the input. + * + * @return void + */ + public function __construct(private readonly array $periods) { + foreach ($periods as $key) { + $this->known[$key] = true; + } + }//end __construct() + + /** + * The period keys, in grid order. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function keys(): array { + return $this->periods; + }//end keys() + + /** + * The periods a lesson of $length periods starting at $start takes, or null when it runs off the day or starts outside the grid. + * + * @param string $start The first period key. + * @param int $length The length in periods. + * + * @return array|null + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function span(string $start, int $length): ?array { + $day = $this->day(key: $start); + $number = $this->number(key: $start); + $keys = []; + for ($offset = 0; $offset < max(1, $length); $offset++) { + $key = $day.'-'.($number + $offset); + if (isset($this->known[$key]) === false) { + return null; + } + + $keys[] = $key; + } + + return $keys; + }//end span() + + /** + * The day of a period key, as in `mon`. + * + * @param string $key The period key. + * + * @return string + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function day(string $key): string { + return explode('-', $key, 2)[0]; + }//end day() + + /** + * The period number of a period key, from 1. + * + * @param string $key The period key. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function number(string $key): int { + return (int)(explode('-', $key, 2)[1] ?? 0); + }//end number() + + /** + * The free periods between the first and the last of a day's taken period numbers. + * + * @param array $numbers Taken period numbers of one day. + * + * @return int + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function gaps(array $numbers): int { + if ($numbers === []) { + return 0; + } + + $unique = array_unique($numbers); + return (max($unique) - min($unique) + 1 - count($unique)); + }//end gaps() +}//end class diff --git a/lib/Timetabling/WishChecker.php b/lib/Timetabling/WishChecker.php new file mode 100644 index 00000000..cf95890e --- /dev/null +++ b/lib/Timetabling/WishChecker.php @@ -0,0 +1,208 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Timetabling; + +/** + * The five wish kinds, evaluated on placed lessons. + * + * A placed lesson (a slot) is: `lesson` (the input lesson), `room`, `periods` + * (the period keys it takes), `day` and `order` (the grid position of its first period). + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ +final class WishChecker { + + /** + * The lesson field a wish on each subject compares with its reference. + */ + private const LESSON_FIELDS = [ + 'teacher' => 'teacher', + 'group' => 'group', + 'activity' => 'activity', + ]; + + /** + * The keys of the lessons that break the wish, in period order; empty when it is kept. + * + * @param array $wish The wish. + * @param array> $slots All placed lessons. + * @param WeekGrid $grid The week grid. + * + * @return array + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function breakingLessons(array $wish, array $slots, WeekGrid $grid): array { + $mine = array_values( + array_filter($slots, fn (array $slot): bool => $this->applies(wish: $wish, lesson: $slot['lesson'], room: (string)$slot['room'])) + ); + usort($mine, static fn (array $one, array $two): int => $one['order'] <=> $two['order']); + + $breaking = match ((string)($wish['kind'] ?? '')) { + 'unavailable', 'avoid' => array_filter($mine, fn (array $slot): bool => $this->touches(wish: $wish, periods: $slot['periods'])), + 'maxPerDay' => $this->overTheLimit(wish: $wish, slots: $mine), + 'noGaps' => $this->daysWithGaps(slots: $mine, grid: $grid), + 'sameRoom' => $this->outsideTheMainRoom(slots: $mine), + default => [], + }; + + return array_values(array_map(static fn (array $slot): string => (string)$slot['lesson']['key'], $breaking)); + }//end breakingLessons() + + /** + * Whether placing the lesson on these periods in this room breaks the wish by itself (the period kinds only). + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param array $periods The periods it would take. + * @param string $room The room. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function forbids(array $wish, array $lesson, array $periods, string $room): bool { + if (in_array(($wish['kind'] ?? ''), ['unavailable', 'avoid'], true) === false) { + return false; + } + + return $this->applies(wish: $wish, lesson: $lesson, room: $room) === true && $this->touches(wish: $wish, periods: $periods) === true; + }//end forbids() + + /** + * Whether a wish is about this lesson. + * + * @param array $wish The wish. + * @param array $lesson The lesson. + * @param string $room The room it is in. + * + * @return bool + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-4.1 + */ + public function applies(array $wish, array $lesson, string $room): bool { + $reference = (string)($wish['reference'] ?? ''); + $appliesTo = (string)($wish['appliesTo'] ?? ''); + if ($appliesTo === 'room') { + return $room === $reference; + } + + $field = (self::LESSON_FIELDS[$appliesTo] ?? null); + return $field !== null && (string)($lesson[$field] ?? '') === $reference; + }//end applies() + + /** + * Whether the periods include one of the wish's periods. + * + * @param array $wish The wish. + * @param array $periods The periods. + * + * @return bool + */ + private function touches(array $wish, array $periods): bool { + return array_intersect($periods, (array)($wish['periods'] ?? [])) !== []; + }//end touches() + + /** + * The lessons past the wish's limit on each day. + * + * @param array $wish The wish. + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function overTheLimit(array $wish, array $slots): array { + $limit = (int)($wish['limit'] ?? 0); + if ($limit < 1) { + return []; + } + + $over = []; + foreach ($this->byDay(slots: $slots) as $day) { + $over = array_merge($over, array_slice($day, $limit)); + } + + return $over; + }//end overTheLimit() + + /** + * The lessons of every day that has a free period between two of them. + * + * @param array> $slots Its lessons, in period order. + * @param WeekGrid $grid The week grid. + * + * @return array> + */ + private function daysWithGaps(array $slots, WeekGrid $grid): array { + $broken = []; + foreach ($this->byDay(slots: $slots) as $day) { + $numbers = []; + foreach ($day as $slot) { + $numbers = array_merge($numbers, array_map([$grid, 'number'], $slot['periods'])); + } + + if ($grid->gaps(numbers: $numbers) > 0) { + $broken = array_merge($broken, $day); + } + } + + return $broken; + }//end daysWithGaps() + + /** + * The lessons that are not in the room most of them use. + * + * @param array> $slots Its lessons, in period order. + * + * @return array> + */ + private function outsideTheMainRoom(array $slots): array { + $counts = array_count_values(array_map(static fn (array $slot): string => (string)$slot['room'], $slots)); + if (count($counts) < 2) { + return []; + } + + arsort($counts); + $main = (string)array_key_first($counts); + return array_filter($slots, static fn (array $slot): bool => (string)$slot['room'] !== $main); + }//end outsideTheMainRoom() + + /** + * Lessons grouped by day, order kept. + * + * @param array> $slots Lessons in period order. + * + * @return array>> + */ + private function byDay(array $slots): array { + $days = []; + foreach ($slots as $slot) { + $days[(string)$slot['day']][] = $slot; + } + + return $days; + }//end byDay() +}//end class diff --git a/openspec/changes/timetabling-generator/tasks.md b/openspec/changes/timetabling-generator/tasks.md index daae39bb..2b566b9e 100644 --- a/openspec/changes/timetabling-generator/tasks.md +++ b/openspec/changes/timetabling-generator/tasks.md @@ -10,18 +10,18 @@ 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 diff --git a/src/dialogs/TimetableWishDialog.vue b/src/dialogs/TimetableWishDialog.vue new file mode 100644 index 00000000..eef77483 --- /dev/null +++ b/src/dialogs/TimetableWishDialog.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/src/icons.js b/src/icons.js index 10192bee..5a210993 100644 --- a/src/icons.js +++ b/src/icons.js @@ -19,6 +19,7 @@ import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.v import AlertOutline from 'vue-material-design-icons/AlertOutline.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import BriefcaseOutline from 'vue-material-design-icons/BriefcaseOutline.vue' +import CalendarClock from 'vue-material-design-icons/CalendarClock.vue' import ChartBar from 'vue-material-design-icons/ChartBar.vue' import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import ChartTimeline from 'vue-material-design-icons/ChartTimeline.vue' @@ -50,6 +51,7 @@ export default { AlertOutline, BookOpenVariantOutline, BriefcaseOutline, + CalendarClock, ChartBar, ChartBoxOutline, ChartTimeline, diff --git a/src/manifest.json b/src/manifest.json index 6441f747..10826b84 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -55,6 +55,9 @@ { "id": "PortfolioIndexMenu", "label": "Portfolios", "icon": "FolderMultipleOutline", "route": "PortfolioIndex" } ] }, { "id": "Timesheet", "label": "Timesheet", "icon": "ClockOutline", "route": "Timesheet", "order": 50 }, + { "id": "TimetableMenu", "label": "Timetable", "icon": "CalendarClock", "route": "TimetableWishes", "order": 55, "permission": "admin", "children": [ + { "id": "TimetableWishesMenu", "label": "Wishes", "icon": "CalendarClock", "route": "TimetableWishes" } + ] }, { "id": "Documentation", "label": "Documentation", "icon": "BookOpenVariantOutline", "href": "https://planix.conduction.nl", "section": "footer", "order": 90 }, { "id": "StoreMenu", "label": "Store", "icon": "StoreOutline", "route": "Store", "section": "footer", "order": 92 }, { "id": "ReportsMenu", "label": "Reports", "icon": "ChartBoxOutline", "route": "Reports", "section": "footer", "order": 95 }, @@ -238,6 +241,19 @@ { "id": "ProjectLog", "_note": "The project log: issues, lessons, meetings and decisions with type filters, plus an Actions filter over the project's tasks with issueType action, and an Add action that creates such a task. The mix of two schemas under one filter row is what makes it custom.", "route": "/projects/:id/log", "type": "custom", "title": "Log", "component": "ProjectLog", "config": {} }, { "id": "TaskDetail", "_note": "Task detail that combines a CnObjectSidebar with an inline time-entry log written against the same task. A detail page renders the record; the embedded logging surface is what makes this custom.", "route": "/projects/:id/tasks/:taskId", "type": "custom", "title": "Task", "component": "TaskDetail", "config": {} }, { "id": "Timesheet", "_note": "Week timesheet: entries grouped per day with inline edit and a project selector. A grouped, editable grid over time entries, which no v2 list primitive expresses.", "route": "/timesheet", "type": "custom", "title": "Timesheet", "component": "Timesheet", "config": {} }, + { "id": "TimetableWishes", "_note": "The wishes the timetable generator weighs, listed by teacher, group, room or activity. Admins add, edit and delete them, which OpenRegister enforces on the timetableWish schema, so the page is a declarative index; only its create and edit dialog is replaced (form-dialog slot) so the periods are picked on the school week grid.", "route": "/timetable/wishes", "type": "index", "title": "Timetable wishes", "config": { + "register": "planninq", + "schema": "timetableWish", + "columns": ["appliesTo", "reference", "kind", "strength", "weight", "note"], + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Teacher", "filter": { "appliesTo": "teacher" } }, + { "label": "Group", "filter": { "appliesTo": "group" } }, + { "label": "Room", "filter": { "appliesTo": "room" } }, + { "label": "Activity", "filter": { "appliesTo": "activity" } } + ], + "defaultSort": [{ "field": "reference", "order": "asc" }] + }, "slots": { "form-dialog": "TimetableWishDialog" } }, { "id": "Boards", "_note": "Board picker: the projects a user can open a board for, as entry points rather than records. The cards route to /projects/:id and expose no record actions.", "route": "/boards", "type": "custom", "title": "Boards", "component": "Boards", "config": {} }, { "id": "PortfolioStatus", "_note": "One portfolio's projects with their lifecycle status, progress, dates, budget and six aspect statuses, under a roll-up per aspect with the worst state and a No report count. It combines the projects, their tasks and an admin setting into one table and a roll-up, which no index or dashboard widget expresses.", "route": "/portfolio/status", "type": "custom", "title": "Portfolio status", "component": "PortfolioStatus", "config": {} }, { "id": "PortfolioFinance", "_note": "Money across one portfolio: a row per project with budget, commitments, actual cost including labour from booked time, forecast and what remains, and the totals, for the projects whose money the viewer may see. It combines finance lines, time entries and projects into computed rows and a cross-checked total, which no index or dashboard widget expresses.", "route": "/portfolio/finance", "type": "custom", "title": "Portfolio finance", "component": "PortfolioFinance", "config": {} }, diff --git a/src/registry.js b/src/registry.js index d33fefe8..eb447a0b 100644 --- a/src/registry.js +++ b/src/registry.js @@ -16,6 +16,7 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. */ +import TimetableWishDialog from './dialogs/TimetableWishDialog.vue' // NOTE: the dashboard's DashboardPanels component is NOT registered here. A // dashboard widget TYPE resolves against the library's own widget catalog via // registerDashboardWidget() in main.js; this registry is for page components @@ -49,6 +50,16 @@ function page(component) { return { kind: 'page', component } } +/** + * A component a manifest page mounts in one of its slots (page.slots). + * + * @param {object} component The Vue component. + * @return {object} Kind-tagged registry entry. + */ +function slot(component) { + return { kind: 'slot', component } +} + export default { Boards: page(Boards), MyWork: page(MyWork), @@ -68,4 +79,5 @@ export default { ProjectTimeline: page(ProjectTimeline), TaskDetail: page(TaskDetail), Timesheet: page(Timesheet), + TimetableWishDialog: slot(TimetableWishDialog), } diff --git a/src/utils/timetableWishes.js b/src/utils/timetableWishes.js new file mode 100644 index 00000000..b6b55667 --- /dev/null +++ b/src/utils/timetableWishes.js @@ -0,0 +1,204 @@ +/** + * Helpers for the timetable wish editor (timetabling-generator, section 3). + * + * A period key names one period of the school week grid, as in `wed-5`: the + * day, a dash and the period number counted from 1. The grid itself is the + * admin setting `timetable_period_grid` (days and periods with start and end). + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +/** + * The days a grid may use, in week order (TimetableGridService::WEEK_DAYS). + */ +export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +/** + * The wish kinds that name periods, and the one that needs a limit. + */ +export const PERIOD_KINDS = ['unavailable', 'avoid'] +export const LIMIT_KIND = 'maxPerDay' + +/** + * Monday to Friday, eight periods of 50 minutes from 08:30 (TimetableGridService::DEFAULT_GRID). + */ +export const DEFAULT_GRID = { + days: ['mon', 'tue', 'wed', 'thu', 'fri'], + periods: [ + { start: '08:30', end: '09:20' }, + { start: '09:20', end: '10:10' }, + { start: '10:10', end: '11:00' }, + { start: '11:00', end: '11:50' }, + { start: '11:50', end: '12:40' }, + { start: '12:40', end: '13:30' }, + { start: '13:30', end: '14:20' }, + { start: '14:20', end: '15:10' }, + ], +} + +/** + * The key of one period. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string} day A week day, as in `wed`. + * @param {number} number The period number, from 1. + * @return {string} The period key, as in `wed-5`. + */ +export function periodKey(day, number) { + return `${day}-${number}` +} + +/** + * The day and number of a period key, or null for a key that is not one. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string} key A period key. + * @return {{day: string, number: number}|null} The parts. + */ +export function parsePeriodKey(key) { + const match = /^(mon|tue|wed|thu|fri|sat|sun)-([1-9][0-9]?)$/.exec(String(key ?? '')) + if (match === null) { + return null + } + return { day: match[1], number: Number(match[2]) } +} + +/** + * The stored grid setting as an object; the default grid when it is missing or unreadable. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string|object|null} raw The `timetable_period_grid` setting. + * @return {{days: string[], periods: Array<{start: string, end: string}>}} The grid. + */ +export function readGrid(raw) { + let grid = raw + if (typeof raw === 'string') { + try { + grid = JSON.parse(raw) + } catch { + grid = null + } + } + const days = Array.isArray(grid?.days) ? grid.days.filter((day) => WEEK_DAYS.includes(day)) : [] + const periods = Array.isArray(grid?.periods) ? grid.periods : [] + if (days.length === 0 || periods.length === 0) { + return DEFAULT_GRID + } + return { days, periods } +} + +/** + * The rows of the period picker: one row per period number, one cell per day. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {{days: string[], periods: Array<{start: string, end: string}>}} grid The grid. + * @param {string[]} selected The chosen period keys. + * @return {Array<{number: number, start: string, end: string, cells: Array<{key: string, day: string, selected: boolean}>}>} The rows. + */ +export function gridRows(grid, selected = []) { + const chosen = new Set(selected) + return grid.periods.map((period, index) => ({ + number: index + 1, + start: period.start, + end: period.end, + cells: grid.days.map((day) => { + const key = periodKey(day, index + 1) + return { key, day, selected: chosen.has(key) } + }), + })) +} + +/** + * The chosen periods with one key switched on or off, in week order. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string[]} selected The chosen period keys. + * @param {string} key The key to switch. + * @return {string[]} The new selection. + */ +export function togglePeriod(selected, key) { + const next = selected.includes(key) ? selected.filter((item) => item !== key) : [...selected, key] + return sortPeriodKeys(next) +} + +/** + * Period keys in week order, then period order; keys that are not period keys are dropped. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {string[]} keys The keys. + * @return {string[]} The sorted keys. + */ +export function sortPeriodKeys(keys) { + return keys + .map((key) => ({ key, parts: parsePeriodKey(key) })) + .filter((item) => item.parts !== null) + .sort((a, b) => (WEEK_DAYS.indexOf(a.parts.day) - WEEK_DAYS.indexOf(b.parts.day)) || (a.parts.number - b.parts.number)) + .map((item) => item.key) +} + +/** + * The form a wish is edited in, from a stored wish or empty for a new one. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object|null} wish A stored wish. + * @return {object} The form values. + */ +export function wishForm(wish) { + return { + appliesTo: wish?.appliesTo ?? 'teacher', + reference: wish?.reference ?? '', + kind: wish?.kind ?? 'unavailable', + periods: sortPeriodKeys(Array.isArray(wish?.periods) ? wish.periods : []), + limit: wish?.limit ?? null, + strength: wish?.strength ?? 'soft', + weight: wish?.weight ?? 1, + note: wish?.note ?? '', + } +} + +/** + * What is wrong with the form, as the field and a message key; empty when it can be saved. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object} form The form values. + * @return {Array<{field: string, message: string}>} The problems. + */ +export function wishProblems(form) { + const problems = [] + if (String(form.reference ?? '').trim() === '') { + problems.push({ field: 'reference', message: 'Say who or what the wish is about.' }) + } + if (PERIOD_KINDS.includes(form.kind) && form.periods.length === 0) { + problems.push({ field: 'periods', message: 'Choose at least one period.' }) + } + if (form.kind === LIMIT_KIND && !(Number.isInteger(Number(form.limit)) && Number(form.limit) >= 1)) { + problems.push({ field: 'limit', message: 'The limit is a whole number of 1 or more.' }) + } + return problems +} + +/** + * The object saved for a form: periods only for a kind that names periods, a limit only + * for "at most a number of lessons a day", and a weight only for a soft wish. + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-3.1 + * @param {object} form The form values. + * @return {object} The wish to save. + */ +export function wishPayload(form) { + const payload = { + appliesTo: form.appliesTo, + reference: String(form.reference).trim(), + kind: form.kind, + periods: PERIOD_KINDS.includes(form.kind) ? sortPeriodKeys(form.periods) : [], + limit: form.kind === LIMIT_KIND ? Number(form.limit) : null, + strength: form.strength, + weight: form.strength === 'soft' ? Math.min(3, Math.max(1, Number(form.weight) || 1)) : null, + } + const note = String(form.note ?? '').trim() + if (note !== '') { + payload.note = note + } + return payload +} diff --git a/tests/e2e/timetable-wishes.spec.ts b/tests/e2e/timetable-wishes.spec.ts new file mode 100644 index 00000000..76847d11 --- /dev/null +++ b/tests/e2e/timetable-wishes.spec.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * E2E coverage for the timetable wish list (timetabling-generator, section 3). + * + * @e2e timetable-generator::a-timetabler-marks-a-wish-as-hard + * + * The hard wish is added through the dialog on the week grid; the soft wish is + * created through the API as the admin, so the list shows both. Both carry a + * run id and are removed at the end. + */ + +import type { APIRequestContext } from '@playwright/test' + +import { expect, request, test } from '@playwright/test' +import { BASE_URL } from './base-url.ts' +import { PLANNINQ_ROOT } from './nav.ts' + +const RUN = Date.now().toString(36) +const OBJECTS = '/index.php/apps/openregister/api/objects/planninq/timetableWish' +const TEACHER = `klaas-${RUN}` + +async function admin(): Promise { + return request.newContext({ + baseURL: BASE_URL, + httpCredentials: { + username: process.env.NC_ADMIN_USER ?? process.env.ADMIN_USER ?? 'admin', + password: process.env.NC_ADMIN_PASS ?? process.env.ADMIN_PASSWORD ?? 'admin', + send: 'always', + }, + extraHTTPHeaders: { 'Content-Type': 'application/json', 'OCS-APIRequest': 'true', Accept: 'application/json' }, + }) +} + +test.describe('Timetable wishes', () => { + let api: APIRequestContext + + test.beforeAll(async () => { + api = await admin() + }) + + test.afterAll(async () => { + const res = await api.get(`${OBJECTS}?reference=${TEACHER}`, { failOnStatusCode: false }) + const body = res.ok() ? await res.json() : { results: [] } + for (const wish of body.results ?? []) { + await api.delete(`${OBJECTS}/${wish.id ?? wish['@self']?.id}`, { failOnStatusCode: false }) + } + await api.dispose() + }) + + test('a timetabler marks a wish as hard', async ({ page }) => { + const soft = await api.post(OBJECTS, { + data: { appliesTo: 'teacher', reference: TEACHER, kind: 'avoid', periods: ['fri-8'], strength: 'soft', weight: 2 }, + }) + expect(soft.ok(), 'create the soft wish').toBe(true) + + await page.goto(`${PLANNINQ_ROOT}timetable/wishes`) + await page.getByRole('button', { name: /add|new/i }).first().click() + await page.getByTestId('wish-reference').locator('input').fill(TEACHER) + for (const period of [5, 6, 7, 8]) { + await page.getByTestId(`wish-period-wed-${period}`).locator('input').check() + } + await page.getByTestId('wish-strength-hard').locator('input').check() + await expect(page.getByTestId('wish-weight')).toHaveCount(0) + await page.getByTestId('wish-save').click() + + const rows = page.getByRole('row').filter({ hasText: TEACHER }) + await expect(rows).toHaveCount(2) + await expect(rows.filter({ hasText: /hard/i })).toHaveCount(1) + await expect(rows.filter({ hasText: /soft/i })).toContainText('2') + + const stored = await api.get(`${OBJECTS}?reference=${TEACHER}&strength=hard`) + const hard = (await stored.json()).results?.[0] + expect(hard).toMatchObject({ kind: 'unavailable', periods: ['wed-5', 'wed-6', 'wed-7', 'wed-8'], weight: null }) + }) +}) diff --git a/tests/unit/Controller/TimetableInputControllerTest.php b/tests/unit/Controller/TimetableInputControllerTest.php new file mode 100644 index 00000000..8f15c62b --- /dev/null +++ b/tests/unit/Controller/TimetableInputControllerTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Controller; + +use OCA\Planninq\Controller\TimetableInputController; +use OCA\Planninq\Service\SettingsService; +use OCA\Planninq\Service\TimetableCsvParser; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Admins only; nothing is kept when a line is refused. + */ +class TimetableInputControllerTest extends TestCase { + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A controller with the real parser and builder, the given body and caller. + * + * @param array $body The request parameters. + * @param bool $admin Whether the caller is an admin. + * + * @return TimetableInputController + */ + private function controller(array $body, bool $admin): TimetableInputController { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback(static fn (string $key): mixed => ($body[$key] ?? null)); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('isCurrentUserAdmin')->willReturn($admin); + + return new TimetableInputController( + request: $request, + parser: new TimetableCsvParser(), + inputBuilder: new TimetableInputBuilder(dispatcher: $this->createMock(IEventDispatcher::class), appConfig: $config, grid: new TimetableGridService(appConfig: $config)), + settingsService: $settings, + ); + }//end controller() + + /** + * The two sheets of the spec's school. + * + * @return array + */ + private function sheets(): array { + return [ + 'rooms' => "reference,capacity,type\nr-101,30,classroom\n", + 'activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,English,klaas,3,1,classroom\n3A,Maths,noor,2,2,classroom\n", + ]; + }//end sheets() + + /** + * An admin uploads both sheets: they are kept and counted. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAnAdminUploadIsKept(): void { + $response = $this->controller(body: $this->sheets(), admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_OK, actual: $response->getStatus()); + self::assertSame(expected: ['rooms' => 1, 'activities' => 2, 'lessons' => 5], actual: $response->getData()); + self::assertCount(expectedCount: 2, haystack: json_decode($this->stored['timetable_csv_activities'], true)); + self::assertSame(expected: 'r-101', actual: json_decode($this->stored['timetable_csv_rooms'], true)[0]['reference']); + }//end testAnAdminUploadIsKept() + + /** + * A non-admin is refused and nothing is kept; the route is admin-only. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testANonAdminIsRefused(): void { + $response = $this->controller(body: $this->sheets(), admin: false)->upload(); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $response->getStatus()); + self::assertSame(expected: [], actual: $this->stored); + self::assertNotSame(expected: [], actual: (new ReflectionMethod(TimetableInputController::class, 'upload'))->getAttributes(AuthorizedAdminSetting::class)); + }//end testANonAdminIsRefused() + + /** + * A refused line keeps nothing and answers with the lines. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARefusedLineKeepsNothing(): void { + $body = ['activities' => "group,subject,teacher,lessons per week,lesson length,room type\n3A,Swimming,piet,1,1,pool\n"] + $this->sheets(); + $response = $this->controller(body: $body, admin: true)->upload(); + + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $response->getStatus()); + self::assertSame(expected: 'unknown-room-type', actual: $response->getData()['errors']['activities'][0]['code']); + self::assertSame(expected: 2, actual: $response->getData()['errors']['activities'][0]['line']); + self::assertSame(expected: [], actual: $this->stored); + + $missing = $this->controller(body: ['rooms' => 'x'], admin: true)->upload(); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $missing->getStatus()); + }//end testARefusedLineKeepsNothing() +}//end class diff --git a/tests/unit/Service/TimetableCsvParserTest.php b/tests/unit/Service/TimetableCsvParserTest.php new file mode 100644 index 00000000..675fdbf0 --- /dev/null +++ b/tests/unit/Service/TimetableCsvParserTest.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +use OCA\Planninq\Service\TimetableCsvParser; +use PHPUnit\Framework\TestCase; + +/** + * Header check, whole numbers and room types, each refusal with its line. + */ +class TimetableCsvParserTest extends TestCase { + + /** + * A good sheet: header case and spaces ignored, semicolons accepted, blank lines skipped. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAGoodSheetBecomesRows(): void { + $parser = new TimetableCsvParser(); + $rooms = $parser->parseRooms(csv: "Reference,Capacity,Type\nr-101,30,classroom\n\nlab-1,16,lab\n"); + self::assertSame(expected: [], actual: $rooms['errors']); + self::assertSame(expected: [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom'], ['reference' => 'lab-1', 'capacity' => 16, 'type' => 'lab']], actual: $rooms['rows']); + + $activities = $parser->parseActivities(csv: " Group ; Subject;Teacher;Lessons per week;Lesson length;Room type\r\n3A;Chemistry;noor;2;2;lab\r\n", roomTypes: ['classroom', 'lab']); + self::assertSame(expected: [], actual: $activities['errors']); + self::assertSame( + expected: [['group' => '3A', 'subject' => 'Chemistry', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'lab']], + actual: $activities['rows'] + ); + }//end testAGoodSheetBecomesRows() + + /** + * A sheet whose first line is not the header is refused as a whole. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testAWrongHeaderIsRefused(): void { + $result = (new TimetableCsvParser())->parseActivities(csv: "group,subject,teacher,hours\n3A,English,klaas,3\n", roomTypes: ['classroom']); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertCount(expectedCount: 1, haystack: $result['errors']); + self::assertSame(expected: 1, actual: $result['errors'][0]['line']); + self::assertSame(expected: 'header', actual: $result['errors'][0]['code']); + self::assertSame(expected: 'The first line must be: group, subject, teacher, lessons per week, lesson length, room type.', actual: $result['errors'][0]['message']); + }//end testAWrongHeaderIsRefused() + + /** + * Lessons per week and lesson length must be whole numbers of 1 or more; an unknown room type is refused; each names its line. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testEachRefusalNamesItsLine(): void { + $csv = implode( + "\n", + [ + 'group,subject,teacher,lessons per week,lesson length,room type', + '3A,English,klaas,3,1,classroom', + '3A,Maths,noor,three,1,classroom', + '3B,Maths,noor,2,1.5,classroom', + '3B,Music,piet,0,1,classroom', + '3B,Swimming,piet,1,2,pool', + '3C,,piet,1,1,classroom', + ] + ); + $result = (new TimetableCsvParser())->parseActivities(csv: $csv, roomTypes: ['classroom']); + + self::assertSame(expected: ['3A:English'], actual: array_map(static fn (array $row): string => $row['group'].':'.$row['subject'], $result['rows'])); + self::assertSame( + expected: [ + [3, 'lessons per week', 'whole-number'], + [4, 'lesson length', 'whole-number'], + [5, 'lessons per week', 'whole-number'], + [6, 'room type', 'unknown-room-type'], + [7, 'subject', 'empty'], + ], + actual: array_map(static fn (array $error): array => [$error['line'], $error['field'], $error['code']], $result['errors']) + ); + self::assertSame(expected: 'Line 6: room type pool is not on the rooms sheet.', actual: $result['errors'][3]['message']); + }//end testEachRefusalNamesItsLine() + + /** + * A room capacity must be a whole number too. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testARoomCapacityMustBeAWholeNumber(): void { + $result = (new TimetableCsvParser())->parseRooms(csv: "reference,capacity,type\nr-101,thirty,classroom\n"); + + self::assertSame(expected: [], actual: $result['rows']); + self::assertSame(expected: 'Line 2: capacity must be a whole number of 1 or more.', actual: $result['errors'][0]['message']); + }//end testARoomCapacityMustBeAWholeNumber() +}//end class diff --git a/tests/unit/Service/TimetableInputBuilderTest.php b/tests/unit/Service/TimetableInputBuilderTest.php new file mode 100644 index 00000000..4e9cbbc4 --- /dev/null +++ b/tests/unit/Service/TimetableInputBuilderTest.php @@ -0,0 +1,183 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Planninq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/RegisterSchemaValidation.php'; + +use OCA\Planninq\Event\TimetableActivitiesQueryEvent; +use OCA\Planninq\Service\TimetableGridService; +use OCA\Planninq\Service\TimetableInputBuilder; +use OCA\Planninq\Tests\Unit\Support\RegisterSchemaValidation; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; + +/** + * The builder asks the hour plan's owner through the real event, falls back to + * the uploaded sheets, and says why an input is empty. + */ +class TimetableInputBuilderTest extends TestCase { + use RegisterSchemaValidation; + + /** + * The app config values, in memory. + * + * @var array + */ + private array $stored = []; + + /** + * A builder whose dispatcher runs $listener on the real event, as learniq's listener would. + * + * @param callable|null $listener Receives the TimetableActivitiesQueryEvent, or null for nobody listening. + * + * @return TimetableInputBuilder + */ + private function builder(?callable $listener): TimetableInputBuilder { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use ($listener): void { + if ($listener !== null && $event instanceof TimetableActivitiesQueryEvent) { + $listener($event); + } + } + ); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + fn (string $app, string $key, string $default = '', bool $lazy = false): string => ($this->stored[$key] ?? $default) + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + } + ); + + return new TimetableInputBuilder(dispatcher: $dispatcher, appConfig: $config, grid: new TimetableGridService(appConfig: $config)); + }//end builder() + + /** + * Learniq's hour plan fixture: 3A has three English lessons and two double maths lessons. + * + * @return array{0:array>,1:array>} + */ + private function hourPlan(): array { + return [ + [ + ['group' => '3A', 'subject' => 'English', 'teacher' => 'klaas', 'lessonsPerWeek' => 3, 'lessonLength' => 1, 'roomType' => 'classroom'], + ['group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'lessonsPerWeek' => 2, 'lessonLength' => 2, 'roomType' => 'classroom'], + ], + [['reference' => 'r-101', 'capacity' => 30, 'type' => 'classroom']], + ]; + }//end hourPlan() + + /** + * An answered event gives one lesson per weekly lesson, the grid's forty periods and the wishes with their ids. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnAnsweredEventBecomesLessons(): void { + [$activities, $rooms] = $this->hourPlan(); + $asked = null; + $input = $this->builder( + static function (TimetableActivitiesQueryEvent $event) use ($activities, $rooms, &$asked): void { + $asked = $event->getAcademicYear(); + $event->answer(app: 'learniq', activities: $activities, rooms: $rooms); + } + )->build( + academicYear: '2026-2027', + wishes: [['@self' => ['id' => 'w-1'], 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard', 'note' => 'not needed']] + ); + + self::assertSame(expected: '2026-2027', actual: $asked); + self::assertSame(expected: 'learniq', actual: $input->source); + self::assertNull(actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + self::assertSame(expected: ['mon-1', 'mon-2'], actual: array_slice($input->periods, 0, 2)); + self::assertSame(expected: 'fri-8', actual: $input->periods[39]); + self::assertSame(expected: $rooms, actual: $input->rooms); + self::assertSame(expected: ['3A:English:1', '3A:English:2', '3A:English:3', '3A:Maths:1', '3A:Maths:2'], actual: array_column($input->lessons, 'key')); + self::assertSame( + expected: ['key' => '3A:Maths:1', 'activity' => '3A:Maths', 'group' => '3A', 'subject' => 'Maths', 'teacher' => 'noor', 'roomType' => 'classroom', 'length' => 2], + actual: $input->lessons[3] + ); + self::assertSame( + expected: [['id' => 'w-1', 'appliesTo' => 'teacher', 'reference' => 'klaas', 'kind' => 'unavailable', 'periods' => ['wed-5'], 'strength' => 'hard']], + actual: $input->wishes + ); + + $scenario = ['title' => 'Autumn', 'source' => 'generated', 'weekOf' => '2026-10-05', 'windowFrom' => '2026-10-05', 'windowTo' => '2026-10-30', 'status' => 'queued', 'input' => $input->toArray()]; + self::assertSame(expected: [], actual: $this->registerSchemaErrors(slug: 'timetableScenario', payload: $scenario), message: 'the input fits the scenario schema'); + }//end testAnAnsweredEventBecomesLessons() + + /** + * Nobody answers and nothing was uploaded: an empty input and the reason. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testAnUnansweredEventWithoutUploadGivesTheReason(): void { + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertTrue(condition: $input->isEmpty()); + self::assertSame(expected: 'none', actual: $input->source); + self::assertSame(expected: 'No activities: learniq did not answer and no CSV was uploaded', actual: $input->reason); + self::assertCount(expectedCount: 40, haystack: $input->periods); + }//end testAnUnansweredEventWithoutUploadGivesTheReason() + + /** + * Nobody answers but an admin uploaded sheets: those are the input. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.2 + */ + public function testUploadedSheetsAreTheFallback(): void { + [$activities, $rooms] = $this->hourPlan(); + $builder = $this->builder(listener: null); + $builder->storeUpload(rooms: $rooms, activities: $activities); + + $input = $builder->build(academicYear: '2026-2027', wishes: []); + self::assertSame(expected: 'csv', actual: $input->source); + self::assertCount(expectedCount: 5, haystack: $input->lessons); + self::assertSame(expected: $rooms, actual: $input->rooms); + }//end testUploadedSheetsAreTheFallback() + + /** + * A narrower grid gives fewer period keys. + * + * @return void + * + * @spec openspec/changes/timetabling-generator/tasks.md#task-2.1 + */ + public function testThePeriodsFollowTheStoredGrid(): void { + $this->stored['timetable_period_grid'] = '{"days":["mon","wed"],"periods":[{"start":"08:00","end":"08:45"},{"start":"08:45","end":"09:30"}]}'; + $input = $this->builder(listener: null)->build(academicYear: '2026-2027', wishes: []); + + self::assertSame(expected: ['mon-1', 'mon-2', 'wed-1', 'wed-2'], actual: $input->periods); + }//end testThePeriodsFollowTheStoredGrid() +}//end class 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/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) + }) +})