diff --git a/application/config/search.php b/application/config/search.php
new file mode 100644
index 000000000..bedf8d888
--- /dev/null
+++ b/application/config/search.php
@@ -0,0 +1,874 @@
+ 'person_id',
+ 'table' => 'public.tbl_person',
+ 'searchfields' => [
+ 'uid' => [
+ 'comparison' => 'equals',
+ 'field' => 'uid',
+ 'join' => [
+ 'table' => "public.tbl_benutzer",
+ 'using' => "person_id"
+ ],
+ '1-n' => true
+ ],
+ 'vorname' => [
+ 'alias' => ['firstname'],
+ 'comparison' => 'similar',
+ 'field' => 'vorname'
+ ],
+ 'nachname' => [
+ 'alias' => ['lastname', 'surename'],
+ 'comparison' => 'similar',
+ 'field' => 'nachname'
+ ],
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => "(vorname || ' ' || nachname)"
+ ],
+ 'email' => [
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp = 'email' AND tbl_kontakt.person_id = tbl_person.person_id"
+ ],
+ "1-n" => true
+ ],
+ 'tel' => [
+ 'alias' => ['phone', 'telefon'],
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp IN ('telefon', 'so.tel', 'mobil') AND tbl_kontakt.person_id = tbl_person.person_id"
+ ],
+ "1-n" => true
+ ],
+ 'preid' => [
+ 'alias' => ['prestudent_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'prestudent_id',
+ 'join' => [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "person_id"
+ ],
+ '1-n' => true
+ ],
+ 'pid' => [
+ 'alias' => ['person_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'person_id'
+ ]
+ ],
+ 'resultfields' => [
+ "ARRAY( SELECT uid FROM public.tbl_benutzer WHERE person_id = p.person_id ) AS uids",
+ "p.person_id",
+ "(p.vorname || ' ' || p.nachname) AS name",
+ "ARRAY( SELECT kontakt FROM public.tbl_kontakt WHERE kontakttyp = 'email' AND person_id=p.person_id ) AS email",
+ "CASE
+ WHEN p.foto IS NOT NULL THEN 'data:image/jpeg' || CONVERT_FROM(DECODE('3b','hex'), 'UTF8') || 'base64,' || p.foto
+ ELSE NULL END
+ AS photo_url"
+ ],
+ 'resultjoin' => "
+ JOIN public.tbl_person p USING (person_id)"
+];
+
+$config['student'] = [
+ 'primarykey' => 'student_uid',
+ 'table' => 'public.tbl_student',
+ 'searchfields' => [
+ 'uid' => [
+ 'comparison' => 'equals',
+ 'field' => 'student_uid'
+ ],
+ 'vorname' => [
+ 'alias' => ['firstname'],
+ 'comparison' => 'similar',
+ 'field' => 'vorname',
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'nachname' => [
+ 'alias' => ['lastname', 'surename'],
+ 'comparison' => 'similar',
+ 'field' => 'nachname',
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => "(vorname || ' ' || nachname)",
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'email' => [
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp = 'email' AND tbl_kontakt.person_id = tbl_prestudent.person_id"
+ ]
+ ],
+ "1-n" => true
+ ],
+ 'tel' => [
+ 'alias' => ['phone', 'telefon'],
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp IN ('telefon', 'so.tel', 'mobil') AND tbl_kontakt.person_id = tbl_prestudent.person_id"
+ ]
+ ],
+ "1-n" => true
+ ],
+ 'stg' => [
+ 'alias' => ['studiengang'],
+ 'comparison' => 'equals',
+ 'field' => "typ || kurzbz",
+ 'join' => [
+ [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ],
+ [
+ 'table' => "public.tbl_studiengang",
+ 'on' => "tbl_studiengang.studiengang_kz = tbl_prestudent.studiengang_kz"
+ ]
+ ]
+ ],
+ 'preid' => [
+ 'alias' => ['prestudent_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'prestudent_id'
+ ],
+ 'pid' => [
+ 'alias' => ['person_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'person_id',
+ 'join' => [
+ 'table' => "public.tbl_prestudent",
+ 'using' => "prestudent_id"
+ ]
+ ]
+ ],
+ 'resultfields' => [
+ "s.student_uid AS uid",
+ "s.matrikelnr",
+ "p.person_id",
+ "(p.vorname || ' ' || p.nachname) AS name",
+ "(s.student_uid || '@" . DOMAIN . "') || ARRAY( SELECT kontakt FROM public.tbl_kontakt WHERE kontakttyp = 'email' AND person_id=p.person_id ) AS email",
+ "CASE
+ WHEN p.foto IS NOT NULL THEN 'data:image/jpeg' || CONVERT_FROM(DECODE('3b','hex'), 'UTF8') || 'base64,' || p.foto
+ ELSE NULL END
+ AS photo_url",
+ "b.aktiv"
+ ],
+ 'resultjoin' => "
+ JOIN public.tbl_student s USING (student_uid)
+ JOIN public.tbl_benutzer b ON(b.uid = s.student_uid)
+ JOIN public.tbl_person p USING(person_id)"
+];
+
+$prestudent_sort = [
+ "Student",
+ "Incoming",
+ "Outgoing",
+ "Diplomand",
+ "Unterbrecher",
+ "Aufgenommener",
+ "Wartender",
+ "Bewerber",
+ "Interessent",
+ "Abgewiesener",
+ "Absolvent",
+ "Abbrecher",
+ "Ausserordentlicher",
+ "Praktikant"
+];
+$prestudent_sort_array = "array['" . implode("','", $prestudent_sort) . "']";
+$config['prestudent'] = [
+ 'primarykey' => 'prestudent_id',
+ 'table' => 'public.tbl_prestudent',
+ 'searchfields' => [
+ 'uid' => [
+ 'comparison' => 'equals',
+ 'field' => 'student_uid',
+ 'join' => [
+ 'table' => "public.tbl_student",
+ 'using' => "prestudent_id"
+ ]
+ ],
+ 'vorname' => [
+ 'alias' => ['firstname'],
+ 'comparison' => 'similar',
+ 'field' => 'vorname',
+ 'join' => [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ],
+ 'nachname' => [
+ 'alias' => ['lastname', 'surename'],
+ 'comparison' => 'similar',
+ 'field' => 'nachname',
+ 'join' => [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ],
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => "(vorname || ' ' || nachname)",
+ 'join' => [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ],
+ 'email' => [
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp = 'email' AND tbl_kontakt.person_id = tbl_prestudent.person_id"
+ ],
+ "1-n" => true
+ ],
+ 'tel' => [
+ 'alias' => ['phone', 'telefon'],
+ 'comparison' => 'similar',
+ 'field' => 'kontakt',
+ 'join' => [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp IN ('telefon', 'so.tel', 'mobil') AND tbl_kontakt.person_id = tbl_prestudent.person_id"
+ ],
+ "1-n" => true
+ ],
+ 'stg' => [
+ 'alias' => ['studiengang'],
+ 'comparison' => 'equals',
+ 'field' => "typ || kurzbz",
+ 'join' => [
+ 'table' => "public.tbl_studiengang",
+ 'using' => "studiengang_kz"
+ ]
+ ],
+ 'preid' => [
+ 'alias' => ['prestudent_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'prestudent_id'
+ ],
+ 'pid' => [
+ 'alias' => ['person_id'],
+ 'comparison' => 'equal-int',
+ 'field' => 'person_id',
+ 'join' => [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'resultfields' => [
+ "ps.prestudent_id",
+ "ps.studiengang_kz",
+ "s.matrikelnr",
+ "p.person_id",
+ "b.uid",
+ "(p.vorname || ' ' || p.nachname) AS name",
+ "(b.uid || '@" . DOMAIN . "') || ARRAY( SELECT kontakt FROM public.tbl_kontakt WHERE kontakttyp = 'email' AND person_id=p.person_id ) AS email",
+ "CASE
+ WHEN p.foto IS NOT NULL THEN 'data:image/jpeg' || CONVERT_FROM(DECODE('3b','hex'), 'UTF8') || 'base64,' || p.foto
+ ELSE NULL END
+ AS photo_url",
+ "UPPER(sg.typ || sg.kurzbz) AS stg_kuerzel",
+ "sg.bezeichnung",
+ "(
+ SELECT bezeichnung_mehrsprachig[(TABLE lang)]
+ FROM public.tbl_status
+ WHERE status_kurzbz = public.get_rolle_prestudent(ps.prestudent_id, NULL)
+ LIMIT 1
+ ) AS status",
+ "COALESCE(
+ (
+ SELECT COALESCE(plan.orgform_kurzbz, pss.orgform_kurzbz)
+ FROM public.tbl_prestudentstatus pss
+ LEFT JOIN lehre.tbl_studienplan plan USING (studienplan_id)
+ WHERE pss.prestudent_id=ps.prestudent_id
+ ORDER BY pss.datum DESC, pss.insertamum DESC, pss.ext_id DESC
+ LIMIT 1
+ ),
+ sg.orgform_kurzbz
+ ) AS orgform",
+ "b.aktiv",
+ "array_position(" . $prestudent_sort_array . ", public.get_rolle_prestudent(ps.prestudent_id, NULL)) AS sort"
+ ],
+ 'resultjoin' => "
+ LEFT JOIN public.tbl_prestudent ps USING (prestudent_id)
+ LEFT JOIN public.tbl_student s ON (ps.prestudent_id = s.prestudent_id)
+ LEFT JOIN public.tbl_benutzer b ON (b.uid = s.student_uid)
+ JOIN public.tbl_person p ON (p.person_id = ps.person_id)
+ LEFT JOIN public.tbl_studiengang sg ON (sg.studiengang_kz = ps.studiengang_kz)"
+];
+
+$config['employee'] = [
+ 'alias' => ['ma', 'mitarbeiter'],
+ 'primarykey' => 'mitarbeiter_uid',
+ 'table' => 'public.tbl_mitarbeiter',
+ 'searchfields' => [
+ 'uid' => [
+ 'alias' => ['mitarbeiter_uid'],
+ 'comparison' => 'equals',
+ 'field' => "mitarbeiter_uid"
+ ],
+ 'vorname' => [
+ 'alias' => ['firstname'],
+ 'comparison' => 'similar',
+ 'field' => "vorname",
+ 'join' => [
+ [
+ 'table' => "public.tbl_benutzer",
+ 'on' => "uid = mitarbeiter_uid"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'nachname' => [
+ 'alias' => ['lastname', 'surename'],
+ 'comparison' => 'similar',
+ 'field' => "nachname",
+ 'join' => [
+ [
+ 'table' => "public.tbl_benutzer",
+ 'on' => "uid = mitarbeiter_uid"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => "(vorname || ' ' || nachname)",
+ 'join' => [
+ [
+ 'table' => "public.tbl_benutzer",
+ 'on' => "uid = mitarbeiter_uid"
+ ],
+ [
+ 'table' => "public.tbl_person",
+ 'using' => "person_id"
+ ]
+ ]
+ ],
+ 'email' => [
+ 'comparison' => 'similar',
+ 'field' => "COALESCE(alias, uid) || '" . '@' . DOMAIN . "'",
+ 'join' => [
+ 'table' => "public.tbl_benutzer",
+ 'on' => "uid = mitarbeiter_uid"
+ ]
+ ],
+ 'tel' => [
+ 'alias' => ['phone', 'telefon'],
+ 'comparison' => 'similar',
+ 'field' => "TRIM(COALESCE(kontakt, '') || ' ' || COALESCE(telefonklappe, ''))",
+ 'join' => [
+ 'table' => "public.tbl_kontakt",
+ 'on' => "kontakttyp = 'telefon' AND tbl_kontakt.standort_id = tbl_mitarbeiter.standort_id"
+ ],
+ "1-n" => true
+ ],
+ 'pid' => [
+ 'alias' => ['person_id'],
+ 'comparison' => 'equal-int',
+ 'field' => "person_id",
+ 'join' => [
+ 'table' => "public.tbl_benutzer",
+ 'on' => "uid = mitarbeiter_uid"
+ ]
+ ],
+ 'oe' => [
+ 'alias' => ['ou', 'organisationseinheit', 'organisationunit'],
+ 'comparison' => 'vector',
+ 'field' => "fts_bezeichnung",
+ 'join' => [
+ [
+ 'table' => "public.tbl_benutzerfunktion",
+ 'on' => "mitarbeiter_uid = uid
+ AND funktion_kurzbz = 'oezuordnung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())"
+ ],
+ [
+ 'table' => "public.tbl_organisationseinheit",
+ 'using' => "oe_kurzbz"
+ ]
+ ],
+ '1-n' => true
+ ],
+ 'kst' => [
+ 'comparison' => 'vector',
+ 'field' => "fts_bezeichnung",
+ 'join' => [
+ [
+ 'table' => "public.tbl_benutzerfunktion",
+ 'on' => "mitarbeiter_uid = uid
+ AND funktion_kurzbz = 'kstzuordnung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())"
+ ],
+ [
+ 'table' => "public.tbl_organisationseinheit",
+ 'using' => "oe_kurzbz"
+ ]
+ ],
+ '1-n' => true
+ ]
+ ],
+ 'resultfields' => [
+ "b.uid",
+ "p.person_id",
+ "(p.vorname || ' ' || p.nachname) AS name",
+ "ARRAY(
+ SELECT
+ '[' || ot.bezeichnung || '] ' || o.bezeichnung AS bezeichnung
+ FROM public.tbl_benutzerfunktion bf
+ JOIN public.tbl_organisationseinheit o USING(oe_kurzbz)
+ JOIN public.tbl_organisationseinheittyp ot USING(organisationseinheittyp_kurzbz)
+ WHERE bf.funktion_kurzbz = 'oezuordnung'
+ AND (bf.datum_von IS NULL OR bf.datum_von <= NOW())
+ AND (bf.datum_bis IS NULL OR bf.datum_bis >= NOW())
+ AND bf.uid = b.uid
+ GROUP BY o.bezeichnung, ot.bezeichnung
+ ) AS organisationunit_name",
+ "COALESCE(b.alias, b.uid) || '" . '@' . DOMAIN . "' AS email",
+ "TRIM(COALESCE(k.kontakt, '') || ' ' || COALESCE(m.telefonklappe, '')) AS phone",
+ "'" . base_url("/cis/public/bild.php?src=person&person_id=") . "' || p.person_id AS photo_url",
+ "ARRAY(
+ SELECT
+ '[' || ot.bezeichnung || '] ' || o.bezeichnung AS bezeichnung
+ FROM public.tbl_benutzerfunktion bf
+ JOIN public.tbl_organisationseinheit o USING(oe_kurzbz)
+ JOIN public.tbl_organisationseinheittyp ot USING(organisationseinheittyp_kurzbz)
+ WHERE bf.funktion_kurzbz = 'kstzuordnung'
+ AND (bf.datum_von IS NULL OR bf.datum_von <= NOW())
+ AND (bf.datum_bis IS NULL OR bf.datum_bis >= NOW())
+ AND bf.uid = b.uid
+ GROUP BY o.bezeichnung, ot.bezeichnung
+ ) AS standardkostenstelle"
+ ],
+ 'resultjoin' => "
+ JOIN public.tbl_mitarbeiter m USING (mitarbeiter_uid)
+ JOIN public.tbl_benutzer b ON (b.uid = m.mitarbeiter_uid)
+ JOIN public.tbl_person p USING(person_id)
+ LEFT JOIN (
+ SELECT kontakt, standort_id
+ FROM public.tbl_kontakt
+ WHERE kontakttyp = 'telefon'
+ ) k ON (k.standort_id = m.standort_id)"
+];
+
+// TODO(chris): move to searchpv21.php
+$config['unassigned_employee'] = $config['employee'];
+$config['unassigned_employee']['alias'] = ['mitarbeiter_ohne_zuordnung'];
+$config['unassigned_employee']['prepare'] = "unassigned_employee AS (
+ SELECT tbl_mitarbeiter.*
+ FROM public.tbl_mitarbeiter
+ LEFT JOIN public.tbl_benutzerfunktion ON (
+ uid = mitarbeiter_uid
+ AND funktion_kurzbz = 'kstzuordnung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ )
+ WHERE tbl_benutzerfunktion.bezeichnung IS NULL
+ UNION
+ SELECT tbl_mitarbeiter.*
+ FROM public.tbl_mitarbeiter
+ LEFT JOIN public.tbl_benutzerfunktion ON (
+ uid = mitarbeiter_uid
+ AND funktion_kurzbz = 'oezuordnung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ )
+ WHERE tbl_benutzerfunktion.bezeichnung IS NULL
+)";
+$config['unassigned_employee']['table'] = "unassigned_employee";
+$config['unassigned_employee']['searchfields']['tel']['join']['on'] = "
+ kontakttyp = 'telefon'
+ AND tbl_kontakt.standort_id = unassigned_employee.standort_id
+";
+$config['unassigned_employee']['renderer'] = 'employee';
+
+$config['organisationunit'] = [
+ 'alias' => ['ou', 'organisationseinheit', 'oe'],
+ 'primarykey' => 'oe_kurzbz',
+ 'table' => 'public.tbl_organisationseinheit',
+ 'searchfields' => [
+ 'uid' => [
+ 'comparison' => 'equals',
+ 'field' => 'uid',
+ 'prepare' => "organisationunit_leader(oe_kurzbz, uid, vorname, nachname) AS (
+ SELECT oe_kurzbz, vorname, nachname, uid
+ FROM public.tbl_benutzerfunktion
+ JOIN public.tbl_benutzer USING (uid)
+ JOIN public.tbl_person USING (person_id)
+ WHERE funktion_kurzbz = 'Leitung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND tbl_benutzer.aktiv = TRUE
+ )",
+ 'join' => [
+ 'table' => "organisationunit_leader",
+ 'using' => "oe_kurzbz"
+ ],
+ '1-n' => true
+ ],
+ 'vorname' => [
+ 'alias' => ['firstname'],
+ 'comparison' => 'similar',
+ 'field' => 'vorname',
+ 'prepare' => "organisationunit_leader(oe_kurzbz, uid, vorname, nachname) AS (
+ SELECT oe_kurzbz, vorname, nachname, uid
+ FROM public.tbl_benutzerfunktion
+ JOIN public.tbl_benutzer USING (uid)
+ JOIN public.tbl_person USING (person_id)
+ WHERE funktion_kurzbz = 'Leitung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND tbl_benutzer.aktiv = TRUE
+ )",
+ 'join' => [
+ 'table' => "organisationunit_leader",
+ 'using' => "oe_kurzbz"
+ ],
+ '1-n' => true
+ ],
+ 'nachname' => [
+ 'alias' => ['lastname', 'surename'],
+ 'comparison' => 'similar',
+ 'field' => 'nachname',
+ 'prepare' => "organisationunit_leader(oe_kurzbz, uid, vorname, nachname) AS (
+ SELECT oe_kurzbz, vorname, nachname, uid
+ FROM public.tbl_benutzerfunktion
+ JOIN public.tbl_benutzer USING (uid)
+ JOIN public.tbl_person USING (person_id)
+ WHERE funktion_kurzbz = 'Leitung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND tbl_benutzer.aktiv = TRUE
+ )",
+ 'join' => [
+ 'table' => "organisationunit_leader",
+ 'using' => "oe_kurzbz"
+ ],
+ '1-n' => true
+ ],
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => "(vorname || ' ' || nachname)",
+ 'prepare' => "organisationunit_leader(oe_kurzbz, uid, vorname, nachname) AS (
+ SELECT oe_kurzbz, vorname, nachname, uid
+ FROM public.tbl_benutzerfunktion
+ JOIN public.tbl_benutzer USING (uid)
+ JOIN public.tbl_person USING (person_id)
+ WHERE funktion_kurzbz = 'Leitung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND tbl_benutzer.aktiv = TRUE
+ )",
+ 'join' => [
+ 'table' => "organisationunit_leader",
+ 'using' => "oe_kurzbz"
+ ],
+ '1-n' => true
+ ],
+ 'oe' => [
+ 'alias' => ['ou', 'organisationseinheit', 'organisationunit'],
+ 'comparison' => 'vector',
+ 'field' => "fts_bezeichnung"
+ ],
+ 'kurzbz' => [
+ 'alias' => ['oe_kurzbz'],
+ 'comparison' => 'equals',
+ 'field' => "oe_kurzbz"
+ ]
+ ],
+ 'resultfields' => [
+ "oe.oe_kurzbz",
+ "('[' || type.bezeichnung || '] ' || oe.bezeichnung) AS name",
+ "oe_parent.oe_kurzbz AS parentoe_kurzbz",
+ "(CASE WHEN oe_parent.bezeichnung IS NOT NULL THEN '[' || type_parent.bezeichnung || '] ' || oe_parent.bezeichnung END) AS parentoe_name",
+ "ARRAY(
+ SELECT JSON_BUILD_OBJECT('uid', b.uid, 'vorname', p.vorname, 'nachname', p.nachname, 'name', (p.vorname || ' ' || p.nachname))
+ FROM public.tbl_benutzerfunktion bf
+ JOIN public.tbl_benutzer b USING (uid)
+ JOIN public.tbl_person p USING (person_id)
+ WHERE funktion_kurzbz = 'Leitung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND b.aktiv = TRUE
+ AND oe_kurzbz = oe.oe_kurzbz
+ ) AS leaders",
+ "(
+ SELECT COUNT(*)
+ FROM public.tbl_benutzerfunktion
+ WHERE funktion_kurzbz = 'oezuordnung'
+ AND (datum_von IS NULL OR datum_von <= NOW())
+ AND (datum_bis IS NULL OR datum_bis >= NOW())
+ AND oe_kurzbz = oe.oe_kurzbz
+ ) AS number_of_people",
+ "(CASE WHEN oe.mailverteiler THEN oe.oe_kurzbz || '" . '@' . DOMAIN . "' END) AS mailgroup"
+ ],
+ 'resultjoin' => "
+ JOIN public.tbl_organisationseinheit oe
+ USING (oe_kurzbz)
+ JOIN public.tbl_organisationseinheittyp type
+ USING (organisationseinheittyp_kurzbz)
+ LEFT JOIN public.tbl_organisationseinheit oe_parent
+ ON (oe_parent.oe_kurzbz = oe.oe_parent_kurzbz)
+ LEFT JOIN public.tbl_organisationseinheittyp type_parent
+ ON (oe_parent.organisationseinheittyp_kurzbz = type_parent.organisationseinheittyp_kurzbz)"
+];
+
+$config['room'] = [
+ 'alias' => ['raum'],
+ 'primarykey' => 'ort_kurzbz',
+ 'table' => 'public.tbl_ort',
+ 'searchfields' => [
+ 'name' => [
+ 'comparison' => 'similar',
+ 'field' => 'ort_kurzbz'
+ ]
+ ],
+ 'resultfields' => [
+ "ort.ort_kurzbz",
+ "ort.gebteil AS building",
+ "ort.ausstattung AS equipment",
+ "ort.stockwerk AS floor",
+ "ort.dislozierung AS room_number",
+ "ort.content_id",
+ "address.ort AS city",
+ "address.plz AS zip",
+ "address.strasse AS street",
+ "ort.max_person",
+ "ort.arbeitsplaetze AS workplaces"
+ ],
+ 'resultjoin' => "
+ JOIN public.tbl_ort ort
+ USING (ort_kurzbz)
+ LEFT JOIN public.tbl_standort
+ USING (standort_id)
+ LEFT JOIN public.tbl_adresse address
+ USING (adresse_id)"
+];
+$sprache = getUserLanguage();
+$config['cms'] = [
+ 'primarykey' => 'contentsprache_id',
+ 'table' => 'campus.tbl_contentsprache',
+ 'prepare' => "
+ cms_auth (content_id) AS (
+ SELECT content_id
+ FROM campus.tbl_content c
+ WHERE NOT EXISTS (SELECT 1 FROM campus.tbl_contentgruppe g WHERE g.content_id=c.content_id)
+ UNION
+ SELECT content_id
+ FROM public.vw_gruppen g
+ JOIN campus.tbl_contentgruppe c USING (gruppe_kurzbz)
+ WHERE uid = (TABLE auth)
+ ),
+ cms_active (content_id, template_kurzbz) AS (
+ SELECT content_id, template_kurzbz
+ FROM cms_auth
+ JOIN campus.tbl_content USING (content_id)
+ WHERE aktiv = TRUE
+ ),
+ cms_active_redirect (content_id) AS (
+ SELECT content_id
+ FROM cms_active
+ WHERE template_kurzbz = 'redirect'
+ ),
+ cms_active_redirect_linked (content_id) AS (
+ SELECT content_id
+ FROM cms_active_redirect
+ JOIN campus.tbl_contentsprache USING (content_id)
+ WHERE LEFT((xpath('string(/content/url)', content))[1]::text, 1) <> '#'
+ ),
+ cms_active_others (content_id) AS (
+ SELECT content_id
+ FROM cms_active
+ WHERE template_kurzbz IN ('contentmittitel', 'contentohnetitel', 'contentmittitel_filterwidget')
+ )
+ ",
+ 'searchfields' => [
+ 'content' => [
+ 'alias' => ['inhalt'],
+ 'comparison' => "vector",
+ 'field' => "(
+ setweight(to_tsvector('simple', COALESCE(titel, '')), 'A')
+ ||
+ setweight(to_tsvector('simple', COALESCE(content, '')::text), 'B')
+ )"
+ ],
+ 'content_id' => [
+ 'alias' => ['id'],
+ 'comparison' => "equal-int",
+ 'field' => "content_id"
+ ],
+ 'lang' => [
+ 'alias' => ['language', 'sprache'],
+ 'comparison' => "equals",
+ 'field' => "sprache"
+ ]
+ ],
+ 'resultfields' => [
+ "contentsprache.content_id",
+ "content.template_kurzbz",
+ "contentsprache.version",
+ "contentsprache.sprache AS language",
+ "contentsprache.titel AS title",
+ "contentsprache.content",
+ "(xpath('string(/content/url)', contentsprache.content))[1] AS content_url"
+ ],
+ 'resultjoin' => "
+ JOIN campus.tbl_contentsprache contentsprache
+ USING (contentsprache_id)
+ JOIN campus.tbl_content content
+ USING (content_id)
+ WHERE content_id IN (
+ SELECT content_id
+ FROM cms_active_redirect_linked
+ UNION
+ SELECT content_id
+ FROM cms_active_others
+ )
+ AND version = campus.get_highest_content_version(content_id)
+ AND contentsprache.sprache = '{$sprache}'"
+];
+
+$config['dms'] = [
+ 'primarykey' => 'dms_id, version',
+ 'table' => 'campus.tbl_dms_version',
+ 'searchfields' => [
+ 'keywords' => [
+ 'alias' => ['keyword', 'keywords', 'schlagwort', 'schlagworte'],
+ 'comparison' => "vector",
+ 'field' => "(to_tsvector('simple', COALESCE(schlagworte, '')))"
+ ]
+ ],
+ 'resultfields' => [
+ "v.dms_id",
+ "v.version",
+ "v.filename",
+ "v.mimetype",
+ "v.name",
+ "v.beschreibung AS description",
+ "v.schlagworte AS keywords"
+ ],
+ 'resultjoin' => "
+ JOIN campus.tbl_dms_version v
+ USING (dms_id, version)
+ WHERE cis_suche = TRUE
+ AND version=(SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id=v.dms_id)
+ AND NOT EXISTS (
+ SELECT
+ 1
+ FROM
+ fue.tbl_projekt_dokument p
+ WHERE p.dms_id = v.dms_id
+ ) AND (
+ NOT EXISTS (
+ WITH RECURSIVE categories (kategorie_kurzbz) AS (
+ SELECT
+ kategorie_kurzbz
+ FROM
+ campus.tbl_dms c
+ WHERE c.dms_id = v.dms_id
+ UNION ALL
+ SELECT
+ cat.parent_kategorie_kurzbz AS kategorie_kurzbz
+ FROM
+ categories
+ JOIN campus.tbl_dms_kategorie cat USING (kategorie_kurzbz)
+ )
+ SELECT
+ 1
+ FROM
+ categories
+ JOIN campus.tbl_dms_kategorie_gruppe USING (kategorie_kurzbz)
+ UNION
+ SELECT
+ 1
+ FROM
+ categories
+ JOIN campus.tbl_dms_kategorie USING (kategorie_kurzbz)
+ WHERE
+ berechtigung_kurzbz IS NOT NULL
+ ) OR EXISTS (
+ WITH RECURSIVE categories (kategorie_kurzbz) AS (
+ SELECT
+ kategorie_kurzbz
+ FROM
+ campus.tbl_dms c
+ WHERE c.dms_id = v.dms_id
+ UNION ALL
+ SELECT
+ cat.parent_kategorie_kurzbz AS kategorie_kurzbz
+ FROM
+ categories
+ JOIN campus.tbl_dms_kategorie cat USING (kategorie_kurzbz)
+ )
+ SELECT
+ 1
+ FROM
+ categories
+ JOIN campus.tbl_dms_kategorie_gruppe USING (kategorie_kurzbz)
+ JOIN public.tbl_benutzergruppe USING(gruppe_kurzbz)
+ WHERE
+ uid = (TABLE auth)
+ )
+ )"
+];
diff --git a/application/config/searchcis.php b/application/config/searchcis.php
new file mode 100644
index 000000000..ce385338f
--- /dev/null
+++ b/application/config/searchcis.php
@@ -0,0 +1,35 @@
+config->item('employee', 'search');
+
+$config['student'] = $CI->config->item('student', 'search');
+unset($config['student']['searchfields']['email']);
+unset($config['student']['searchfields']['tel']);
+$config['student']['resultfields'] = [
+ "s.student_uid AS uid",
+ "s.matrikelnr",
+ "p.person_id",
+ "(p.vorname || ' ' || p.nachname) AS name",
+ "ARRAY[s.student_uid || '@' || '" . DOMAIN . "'] AS email",
+ "CASE
+ WHEN p.foto IS NOT NULL THEN 'data:image/jpeg' || CONVERT_FROM(DECODE('3b','hex'), 'UTF8') || 'base64,' || p.foto
+ ELSE NULL END
+ AS photo_url",
+ "b.aktiv"
+];
+
+$config['organisationunit'] = $CI->config->item('organisationunit', 'search');
+$config['organisationunit']['prepare'] = 'active_organisationseinheit AS (SELECT * FROM public.tbl_organisationseinheit WHERE aktiv = true)';
+$config['organisationunit']['table'] = 'active_organisationseinheit';
+
+$config['room'] = $CI->config->item('room', 'search');
+
+$config['cms'] = $CI->config->item('cms', 'search');
+
+$config['dms'] = $CI->config->item('dms', 'search');
diff --git a/application/config/searchfunctions.php b/application/config/searchfunctions.php
new file mode 100644
index 000000000..276652997
--- /dev/null
+++ b/application/config/searchfunctions.php
@@ -0,0 +1,35 @@
+ 4,
+ 'rank' => "0",
+ 'compare' => "{field}::text = {word}::text",
+ 'force_integer' => true
+];
+
+$config['equals'] = [
+ 'priority' => 3,
+ 'rank' => "0",
+ 'compare' => "LOWER({field}) = {word}"
+];
+
+$config['similar'] = [
+ 'priority' => 2,
+ 'rank' => "(COALESCE({field}, '') <->> {word})",
+ 'compare' => "COALESCE({field}, '') %> {word}",
+ 'compare_boolean' => "COALESCE({field}, '') ILIKE {like:word}"
+];
+
+$config['vector'] = [
+ 'priority' => 1,
+ 'rank' => "ts_rank({field}, to_tsquery('simple', {word}))",
+ 'compare' => "to_tsquery('simple', {word}) @@ {field}"
+];
+
diff --git a/application/config/searchstv.php b/application/config/searchstv.php
new file mode 100644
index 000000000..96c118ac8
--- /dev/null
+++ b/application/config/searchstv.php
@@ -0,0 +1,11 @@
+config->item('student', 'search');
+
+$config['prestudent'] = $CI->config->item('prestudent', 'search');
diff --git a/application/controllers/api/frontend/v1/Language.php b/application/controllers/api/frontend/v1/Language.php
new file mode 100644
index 000000000..797af4804
--- /dev/null
+++ b/application/controllers/api/frontend/v1/Language.php
@@ -0,0 +1,47 @@
+.
+ */
+
+if (! defined('BASEPATH')) exit('No direct script access allowed');
+
+/**
+ * This controller operates between (interface) the JS (GUI) and the back-end
+ * Provides data to the ajax get calls about languages
+ * This controller works with JSON calls on the HTTP GET and the output is always JSON
+ */
+class Language extends FHCAPI_Controller
+{
+ public function __construct()
+ {
+ parent::__construct([
+ 'get' => self::PERM_LOGGED
+ ]);
+
+ // Load models
+ $this->load->model('system/Sprache_model', 'SpracheModel');
+ }
+
+ public function get()
+ {
+ $this->SpracheModel->addOrder('sprache');
+
+ $result = $this->SpracheModel->load();
+ $data = $this->getDataOrTerminateWithError($result);
+
+ $this->terminateWithSuccess($data);
+ }
+}
diff --git a/application/controllers/api/frontend/v1/Searchbar.php b/application/controllers/api/frontend/v1/Searchbar.php
index 8b383e042..363b6e534 100644
--- a/application/controllers/api/frontend/v1/Searchbar.php
+++ b/application/controllers/api/frontend/v1/Searchbar.php
@@ -35,11 +35,10 @@ class Searchbar extends FHCAPI_Controller
{
// NOTE(chris): additional permission checks will be done in SearchBarLib
parent::__construct([
- 'search' => self::PERM_LOGGED
+ 'search' => self::PERM_LOGGED,
+ 'searchCis' => self::PERM_LOGGED,
+ 'searchStv' => self::PERM_LOGGED
]);
-
- // Load the library SearchBarLib
- $this->load->library('SearchBarLib');
}
//------------------------------------------------------------------------------------------------------------------
@@ -50,6 +49,7 @@ class Searchbar extends FHCAPI_Controller
*/
public function search()
{
+ $this->load->library('SearchBarLib');
$this->load->library('form_validation');
// Checks if the searchstr and the types parameters are in the POSTed JSON
@@ -63,7 +63,53 @@ class Searchbar extends FHCAPI_Controller
$result = $this->searchbarlib->search($this->input->post(self::SEARCHSTR_PARAM), $this->input->post(self::TYPES_PARAM));
if (property_exists($result, 'error'))
$this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL);
- $this->terminateWithSuccess($result);
+
+ $this->addMeta('mode', 'simple');
+
+ $this->terminateWithSuccess($result->data);
+ }
+
+ /**
+ * Gets a JSON body via HTTP POST and provides the parameters
+ */
+ public function searchCis()
+ {
+ return $this->searchAdvanced([ 'config' => 'searchcis' ]);
+ }
+
+ /**
+ * Gets a JSON body via HTTP POST and provides the parameters
+ */
+ public function searchStv()
+ {
+ return $this->searchAdvanced([ 'config' => 'searchstv' ]);
+ }
+
+ /**
+ * Gets a JSON body via HTTP POST and provides the parameters
+ */
+ private function searchAdvanced($config)
+ {
+ $this->load->library('SearchLib', $config);
+ $this->load->library('form_validation');
+
+ // Checks if the searchstr and the types parameters are in the POSTed JSON
+ $this->form_validation->set_rules(self::SEARCHSTR_PARAM, null, 'required');
+ $this->form_validation->set_rules(self::TYPES_PARAM . '[]', null, 'required');
+
+ if (!$this->form_validation->run())
+ $this->terminateWithValidationErrors($this->form_validation->error_array());
+
+ // Convert to json the result from searchlib->search
+ $result = $this->searchlib->search($this->input->post(self::SEARCHSTR_PARAM), $this->input->post(self::TYPES_PARAM));
+
+ $data = $this->getDataOrTerminateWithError($result);
+
+ $this->addMeta('time', $result->meta['time']);
+ $this->addMeta('searchstring', $result->meta['searchstring']);
+ $this->addMeta('mode', 'advanced');
+
+ $this->terminateWithSuccess($data);
}
}
diff --git a/application/core/FHCAPI_Controller.php b/application/core/FHCAPI_Controller.php
index 6697953bc..dad56334d 100644
--- a/application/core/FHCAPI_Controller.php
+++ b/application/core/FHCAPI_Controller.php
@@ -109,10 +109,15 @@ class FHCAPI_Controller extends Auth_Controller
$error = [];
if (is_array($data)) {
- if ($type == self::ERROR_TYPE_VALIDATION)
+ if ($type == self::ERROR_TYPE_VALIDATION) {
$error['messages'] = $data;
- else
+ } elseif (array_is_list($data)) {
+ foreach ($data as $d)
+ $this->addError($d, $type);
+ return;
+ } else {
$error = $data;
+ }
} elseif (is_object($data)) {
$error = (array)$data;
} else {
diff --git a/application/helpers/hlp_common_helper.php b/application/helpers/hlp_common_helper.php
index 40aed007c..00c0a1b93 100644
--- a/application/helpers/hlp_common_helper.php
+++ b/application/helpers/hlp_common_helper.php
@@ -424,6 +424,23 @@ function isValidDate($dateString)
}
+// ------------------------------------------------------------------------
+// PHP functions that don't exist in older versions
+// ------------------------------------------------------------------------
+
+/**
+ * Returns true if the given array is sequential
+ */
+if (!function_exists('array_is_list')) {
+ function array_is_list(array $arr)
+ {
+ if ($arr === []) {
+ return true;
+ }
+ return array_keys($arr) === range(0, count($arr) - 1);
+ }
+}
+
// ------------------------------------------------------------------------
// Collection of utility functions for form validation purposes
// ------------------------------------------------------------------------
diff --git a/application/libraries/SearchLib.php b/application/libraries/SearchLib.php
new file mode 100644
index 000000000..24894eab5
--- /dev/null
+++ b/application/libraries/SearchLib.php
@@ -0,0 +1,1091 @@
+.
+ */
+
+if (! defined('BASEPATH')) exit('No direct script access allowed');
+
+use \stdClass as stdClass;
+
+/**
+ * This is a alternative for SearchBarLib for advanced searches
+ */
+class SearchLib
+{
+ // Error constats
+ const ERROR_WRONG_JSON = 'ERR001';
+ const ERROR_WRONG_SEARCHSTR = 'ERR002';
+ const ERROR_NO_TYPES = 'ERR003';
+ const ERROR_WRONG_TYPES = 'ERR004';
+ const ERROR_NOT_AUTH = 'ERR005';
+
+ private $_ci; // Code igniter instance
+
+ private $_searchfunction_priorities = [];
+ private $_numeric_searchfunctions = [];
+ private $_allowed_searchfunctions = [];
+
+ /**
+ * Gets the CI instance and loads model
+ *
+ * @param array $params
+ * @return void
+ */
+ public function __construct($params = null)
+ {
+ $this->_ci =& get_instance(); // get code igniter instance
+
+ $config = $params['config'] ?? null;
+ // It is loaded only to have the DB functions available
+ $this->_ci->load->model('person/Benutzer_model', 'BenutzerModel');
+
+ // Load Config
+ $this->_ci->load->config('search', true, (boolean)$config);
+ $this->_ci->load->config('searchfunctions', true);
+ if ($config) {
+ $this->_ci->load->config($config, true);
+ $this->_ci->config->set_item('search', $this->_ci->config->item($config));
+ }
+
+ $this->_ci->load->library('PhrasesLib', [['search'], null], 'search_phrases');
+
+ // Precompute helper arrays
+ foreach ($this->_ci->config->item('searchfunctions') as $key => $arr) {
+ $this->_searchfunction_priorities[$key] = $arr['priority'];
+ if ($arr['force_integer'] ?? false)
+ $this->_numeric_searchfunctions[] = $key;
+ $this->_allowed_searchfunctions[] = $key;
+ }
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Public methods
+
+ /**
+ * It performes the search of the given search string using the specified search types
+ *
+ * @param string $searchstring
+ * @param array $types (optional)
+ *
+ * @return stdClass containing an array with the result on index 0
+ * and the overall query time on index 1.
+ */
+ public function search($searchstring, $types = [])
+ {
+ if (!$types) {
+ $types = $this->_ci->config->item('search');
+ } else {
+ $tmp = [];
+ $missing = [];
+ foreach ($types as $type) {
+ $typeconfig = $this->_ci->config->item($type, 'search');
+ if (!$typeconfig) {
+ $missing[] = $type;
+ } else {
+ $tmp[$type] = $typeconfig;
+ }
+ }
+ if ($missing) {
+ $p = $this->_ci->search_phrases;
+ return error(array_map(function ($type) use ($p) {
+ return $p->t('search', 'error_missing_config', [
+ 'type' => $type
+ ]);
+ }, $missing));
+ }
+ $types = $tmp;
+ }
+
+
+ // Convert searchstring into array
+ list($searchArray, $searchstring) = $this->_convertQuery($searchstring, $types);
+
+
+ $sql = $this->getDynamicSearchSqls($searchArray, array_keys($types));
+ if (isError($sql))
+ return $sql;
+ if (!hasData($sql)) {
+ $retval = success([]);
+ $retval->meta = ['time' => 0, 'searchstring' => $searchstring];
+ return $retval;
+ }
+
+ $msc = microtime(true);
+ $result = $this->_ci->BenutzerModel->execReadOnlyQuery(getData($sql));
+ $msc = microtime(true) - $msc;
+
+ if (isError($result))
+ return $result;
+
+ $retval = success($result->retval);
+ $retval->meta = [
+ 'time' => $msc,
+ 'searchstring' => $searchstring
+ ];
+
+ return $retval;
+ }
+
+ /**
+ * Generates the search query for the given search string and the
+ * specified search type.
+ *
+ * @param array $searchArray
+ * @param string $table
+ *
+ * @return stdClass containing the query string.
+ */
+ public function getDynamicSearchSql($searchArray, $table)
+ {
+ $res = $this->checkConfig($table);
+ if (isError($res))
+ return $res;
+ $table_config = getData($res);
+
+ $sql_with = [];
+
+ $sql_select = $this->prepareDynamicSearchSql($sql_with, $searchArray, $table);
+
+ if (!$sql_select)
+ return success("");
+
+ $lang = getUserLanguage();
+
+ $output = "WITH";
+ if ($sql_with && $sql_with[0] === 'RECURSIVE') {
+ $output .= " RECURSIVE";
+ array_shift($sql_with);
+ }
+
+ $output .= "
+ lang (index) AS (
+ SELECT index
+ FROM public.tbl_sprache
+ WHERE sprache=" . $this->_ci->db->escape($lang) . "
+ LIMIT 1
+ ),
+ auth (uid) AS (
+ SELECT " . $this->_ci->db->escape(getAuthUID()) . " AS uid
+ )";
+
+ if ($sql_with) {
+ $sql_with = array_unique($sql_with);
+ $output .= ", " . implode(", ", $sql_with);
+ }
+
+ $other_selects = "";
+ if (isset($table_config['resultfields']))
+ $other_selects = implode(", ", $table_config['resultfields']);
+ if ($other_selects)
+ $other_selects = ", " . $other_selects;
+
+ $output .= "
+ , q (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
+ SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", MAX(rank)
+ FROM (" . implode(" UNION ", $sql_select) . ") q
+ GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . "
+ )
+ SELECT
+ " . $this->_ci->db->escape($table) . " AS type,
+ q.rank
+ " . $other_selects . "
+ FROM q
+ " . ($table_config['resultjoin'] ?? "") . "
+ ORDER BY rank DESC
+ ";
+
+ return success($output);
+ }
+
+ /**
+ * Generates the search query for the given search string and the
+ * specified search types.
+ *
+ * @param array $searchArray
+ * @param array $types
+ *
+ * @return stdClass containing the query string.
+ */
+ public function getDynamicSearchSqls($searchArray, $types)
+ {
+ $with = [];
+ $selects = [];
+ foreach ($types as $type) {
+ $res = $this->checkConfig($type);
+ if (isError($res))
+ return $res;
+ $table_config = getData($res);
+
+ $select = $this->prepareDynamicSearchSql($with, $searchArray, $type);
+ if (!$select)
+ continue;
+
+ $with[] = "final_" . $type . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
+ SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", MAX(rank)
+ FROM (" . implode(" UNION ", $select) . ") q
+ GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . "
+ )";
+
+ $renderer = $table_config['renderer'] ?? $type;
+ $selects[] = "
+ SELECT
+ " . $this->_ci->db->escape($renderer) . " AS renderer,
+ " . $this->_ci->db->escape($type) . " AS type,
+ rank,
+ TO_JSONB((SELECT x FROM (SELECT " . implode(", ", $table_config['resultfields'] ?? ['*']) . ") x)) AS data
+ FROM final_" . $type . "
+ " . ($table_config['resultjoin'] ?? "");
+ }
+
+ if (!$selects)
+ return success("");
+
+ $recursive = "";
+ if ($with && $with[0] === "RECURSIVE") {
+ $recursive = "RECURSIVE ";
+ array_shift($with);
+ }
+
+ $with = array_unique($with);
+
+ $lang = getUserLanguage();
+ array_unshift($with, "lang (index) AS (
+ SELECT index
+ FROM public.tbl_sprache
+ WHERE sprache=" . $this->_ci->db->escape($lang) . "
+ LIMIT 1
+ )");
+ array_unshift($with, "auth (uid) AS (
+ SELECT " . $this->_ci->db->escape(getAuthUID()) . " AS uid
+ )");
+
+ return success("
+ WITH " . $recursive . implode(", ", $with) . "
+ SELECT *
+ FROM (" . implode(" UNION ", $selects) . ") q
+ ORDER BY rank DESC
+ LIMIT 100
+ ");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Protected methods
+
+ /**
+ * Check config
+ *
+ * @param string $name
+ *
+ * @return stdClass
+ */
+ protected function checkConfig($name)
+ {
+ $table_config = $this->_ci->config->item($name, 'search');
+
+ if (!$table_config)
+ return error($this->_ci->search_phrases->t('search', 'error_missing_config', [
+ 'type' => $name
+ ]));
+
+ $errors = [];
+ if (!isset($table_config['table'])
+ || !is_string($table_config['table'])
+ || !$table_config['table']
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
+ 'type' => $name,
+ 'field' => 'table'
+ ]);
+ }
+ if (!isset($table_config['primarykey'])
+ || !is_string($table_config['primarykey'])
+ || !$table_config['primarykey']
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
+ 'type' => $name,
+ 'field' => 'primarykey'
+ ]);
+ }
+ if (!isset($table_config['resultfields'])
+ || !is_array($table_config['resultfields'])
+ || !$table_config['resultfields']
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
+ 'type' => $name,
+ 'field' => 'resultfields'
+ ]);
+ }
+ if (!isset($table_config['searchfields'])
+ || !is_array($table_config['searchfields'])
+ || !$table_config['searchfields']
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config', [
+ 'type' => $name,
+ 'field' => 'searchfields'
+ ]);
+ } else {
+ foreach ($table_config['searchfields'] as $searchfield => $config) {
+ if (!isset($config['field'])
+ || !is_string($config['field'])
+ || !$config['field']
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config_searchfield', [
+ 'type' => $name,
+ 'searchfield' => $searchfield,
+ 'field' => 'field'
+ ]);
+ }
+ if (!isset($config['comparison'])
+ || !is_string($config['comparison'])
+ || !in_array($config['comparison'], $this->_allowed_searchfunctions)
+ ) {
+ $errors[] = $this->_ci->search_phrases->t('search', 'error_invalid_config_searchfield', [
+ 'type' => $name,
+ 'searchfield' => $searchfield,
+ 'field' => 'comparison'
+ ]);
+ }
+ }
+ }
+
+ if ($errors)
+ return error($errors);
+
+ return success($table_config);
+ }
+
+ /**
+ * Generates the with statements for the given search string and the
+ * specified search type.
+ *
+ * @param array &$sqlWith
+ * @param array $searchArray
+ * @param string $table
+ *
+ * @return string a query string or the name of the prepared select.
+ */
+ protected function prepareDynamicSearchSql(&$sqlWith, $searchArray, $table)
+ {
+ $table_config = $this->_ci->config->item($table, 'search');
+
+ $id_offset = count($sqlWith);
+
+
+ $allowed_codes_w_order = ['' => 0, '!' => -1];
+ $max = max($this->_searchfunction_priorities);
+ foreach ($table_config['searchfields'] as $code => $config) {
+ $allowed_codes_w_order[$code] = $this->_searchfunction_priorities[$config['comparison']];
+ $allowed_codes_w_order['!' . $code] = $this->_searchfunction_priorities[$config['comparison']] - $max - 2;
+ }
+
+ $check_order = $this->_searchfunction_priorities;
+ uasort($table_config['searchfields'], function ($a, $b) use ($check_order) {
+ return $check_order[$b['comparison']] - $check_order[$a['comparison']];
+ });
+
+ $integer_functions = $this->_numeric_searchfunctions;
+ $integer_fields = array_keys(array_filter($table_config['searchfields'], function ($a) use ($integer_functions) {
+ return in_array($a['comparison'], $integer_functions);
+ }));
+
+ $only_integer_fields = count($integer_fields) == count($table_config['searchfields']);
+
+ $aliases = [];
+ foreach ($table_config['searchfields'] as $field => $config) {
+ if (isset($config['alias'])) {
+ foreach ($config['alias'] as $alias) {
+ $aliases[$alias] = $field;
+ $aliases['!' . $alias] = '!' . $field;
+ }
+ }
+ }
+
+ $sql_select = [];
+
+ if (isset($table_config['prepare'])) {
+ $this->_addPreparesToSqlWith($sqlWith, $table_config['prepare']);
+ }
+
+ foreach ($searchArray as $or_search) {
+ if (isset($or_search['-filter']) && !in_array($table, $or_search['-filter']))
+ continue;
+ unset($or_search['-filter']);
+
+ foreach ($aliases as $alias => $field) {
+ if (isset($or_search[$alias])) {
+ $or_search[$field] = array_merge($or_search[$alias], $or_search[$field] ?? []);
+ unset($or_search[$alias]);
+ }
+ }
+
+ // NOTE(chris): early out if not allowed fields are in the search array
+ $used_codes = array_keys($or_search);
+ if (count(array_intersect($used_codes, array_keys($allowed_codes_w_order))) != count($used_codes))
+ continue;
+
+ // NOTE(chris): expand general excludes to all fields
+ if (isset($or_search['!'])) {
+ $not = $or_search['!'];
+ unset($or_search['!']);
+ foreach ($table_config['searchfields'] as $code => $config) {
+ if (isset($or_search['!' . $code]))
+ $or_search['!' . $code] = array_unique(array_merge($or_search['!' . $code], $not));
+ else
+ $or_search['!' . $code] = $not;
+ }
+ }
+
+ // NOTE(chris): early out if all searchfields require an integer and at least one searchword is not a number
+ if ($only_integer_fields
+ && isset($or_search[""])
+ && $this->_hasAtLeastOneNaN($or_search[""])
+ ) {
+ continue;
+ }
+
+ $skip = false;
+ foreach ($integer_fields as $code) {
+ // NOTE(chris): filter non integer for integer fields
+ if (isset($or_search['!' . $code])) {
+ $or_search['!' . $code] = array_filter($or_search['!' . $code], function ($a) {
+ return is_numeric($a);
+ });
+ if (!$or_search['!' . $code])
+ unset($or_search['!' . $code]);
+ }
+ // NOTE(chris): early out if a searchword that is not a number is compared to a searchfield that requires an integer
+ if (isset($or_search[$code])
+ && $this->_hasAtLeastOneNaN($or_search[$code])
+ ) {
+ $skip = true;
+ break;
+ }
+ }
+ if ($skip)
+ continue;
+
+ // NOTE(chris): sort for performance reasons
+ uksort($or_search, function ($a, $b) use ($allowed_codes_w_order) {
+ return $allowed_codes_w_order[$b] - $allowed_codes_w_order[$a];
+ });
+
+ $or_with = [];
+ $or_select = [];
+ $or_prepare = [];
+
+ if (substr(key($or_search), 0, 1) == '!') {
+ // NOTE(chris): only negative searchwords
+ $sql = [];
+ foreach ($or_search as $code => $words) {
+ $code = substr($code, 1);
+ // NOTE(chris): sort for performance reasons
+ usort($words, function ($a, $b) {
+ return strlen($b) - strlen($a);
+ });
+ $field_config = $table_config['searchfields'][$code];
+
+ if (isset($field_config['prepare'])) {
+ $this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
+ $or_prepare[$code] = $field_config['prepare'];
+ unset($table_config['searchfields'][$code]['prepare']);
+ unset($field_config['prepare']);
+ }
+ $field_sql = "
+ SELECT
+ " . $this->_formatPrimarykeys($table_config['primarykey'], $table_config['table']) . "
+ FROM " . $table_config['table'] . "
+ " . $this->_makeJoin($field_config['join'] ?? '') . "
+ WHERE ";
+ foreach ($words as $word) {
+ $sql[] = $field_sql . $this->_makeCompareBool($field_config['comparison'], $field_config['field'], $word);
+ }
+ }
+
+ $or_select[] = "
+ SELECT
+ " . $this->_formatPrimarykeys($table_config['primarykey'], $table_config['table']) . ",
+ 1.0 AS rank
+ FROM " . $table_config['table'] . "
+ WHERE " . $table_config['primarykey'] . " NOT IN (" . implode(" UNION ", $sql) . ")";
+ } else {
+ $current_select = false;
+ $count = 0;
+ $skip = false;
+ foreach ($or_search as $code => $words) {
+ // NOTE(chris): sort for performance reasons
+ if ($code && substr($code, 0, 1) == '!') {
+ usort($words, function ($a, $b) {
+ return strlen($a) - strlen($b);
+ });
+ } else {
+ usort($words, function ($a, $b) {
+ return strlen($b) - strlen($a);
+ });
+ }
+ if ($code == '') {
+ foreach ($words as $i => $word) {
+ $field_sql = [];
+ foreach ($table_config['searchfields'] as $c => $field_config) {
+ if (in_array($field_config['comparison'], $integer_functions) && !is_numeric($word))
+ continue;
+
+ $word_from = $table_config['table'];
+ $word_join = "";
+ $word_rank = "0";
+ if ($current_select) {
+ $word_from = $current_select;
+ if ($this->_needBasicTableJoin($field_config['field'], $table_config['primarykey'])) {
+ $word_join .= " " . $this->_makeJoin($table_config);
+ }
+ $word_rank = "rank";
+ }
+ if (isset($field_config['prepare'])) {
+ $this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
+ $or_prepare[$c] = $field_config['prepare'];
+ unset($table_config['searchfields'][$c]['prepare']);
+ unset($field_config['prepare']);
+ }
+ if (isset($field_config['join'])) {
+ $word_join .= " " . $this->_makeJoin($field_config['join']);
+ }
+ $field_sql[] = "
+ SELECT
+ " . $this->_formatPrimarykeys($table_config['primarykey'], $word_from) . ",
+ " . $word_rank . " AS w_rank,
+ " . $this->_makeRank($field_config['comparison'], $field_config['field'], $word) . " AS rank
+ FROM " . $word_from . "
+ " . $word_join . "
+ WHERE " . $this->_makeCompare($field_config['comparison'], $field_config['field'], $word);
+ }
+ // NOTE(chris): skip because the word is not numeric but all searchfields require integers
+ if (!$field_sql) {
+ $or_with = [];
+ $or_select = [];
+ $count = 0;
+ $skip = true;
+ foreach ($or_prepare as $k => $v)
+ $table_config['searchfields'][$k]['prepare'] = $v;
+ break;
+ }
+
+ $id = "w" . ($id_offset + count($or_with));
+ $or_with[] = "
+ " . $id . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
+ SELECT
+ " . $this->_formatPrimarykeys($table_config['primarykey']) . ",
+ (w_rank + 1.0 - CASE " .
+ "WHEN MIN(rank) = 0 THEN 0 " .
+ "ELSE EXP(SUM(LN(CASE WHEN rank = 0 THEN 1 ELSE rank " .
+ "END))) END) AS rank
+ FROM (" . implode(' UNION ALL ', $field_sql) . ") " . $id . "
+ GROUP BY " . $this->_formatPrimarykeys($table_config['primarykey']) . ", w_rank
+ )";
+ $current_select = $id;
+ }
+ } else {
+ foreach ($words as $i => $word) {
+ $where = "";
+ $rank = "";
+ $jointype = "";
+ if (substr($code, 0, 1) == '!') {
+ $c = substr($code, 1);
+ $field_config = $table_config['searchfields'][$c];
+
+ $rank = "1";
+
+ $jointype = "LEFT";
+
+ $where = $field_config['field'] .
+ " IS NULL OR NOT (" .
+ $this->_makeCompareBool(
+ $field_config['comparison'],
+ $field_config['field'],
+ $word
+ ) .
+ ")";
+ if ($field_config['1-n'] ?? false) {
+ $where = "GROUP BY " .
+ $this->_formatPrimarykeys($table_config['primarykey'], $current_select ?: $table_config['table']) .
+ ", rank HAVING MIN(CASE WHEN " .
+ $where .
+ " THEN 1 ELSE 0 END) = 1";
+ } else {
+ $where = "WHERE " . $where;
+ }
+ } else {
+ $field_config = $table_config['searchfields'][$code];
+
+ $rank = $this->_makeRank($field_config['comparison'], $field_config['field'], $word);
+
+ $where = $this->_makeCompare($field_config['comparison'], $field_config['field'], $word);
+ $where = "WHERE " . $where;
+ }
+ $word_from = $table_config['table'];
+ $word_join = "";
+ $word_rank = "";
+ if ($current_select) {
+ $word_from = $current_select;
+ if ($this->_needBasicTableJoin($field_config['field'], $table_config['primarykey'])) {
+ $word_join .= " " . $this->_makeJoin($table_config);
+ }
+ $word_rank = "rank + ";
+ }
+ if (isset($field_config['prepare'])) {
+ $this->_addPreparesToSqlWith($or_with, $field_config['prepare']);
+ $or_prepare[$code] = $field_config['prepare'];
+ unset($table_config['searchfields'][$code]['prepare']);
+ unset($field_config['prepare']);
+ }
+ if (isset($field_config['join'])) {
+ $word_join .= " " . $this->_makeJoin($field_config['join'], $jointype);
+ }
+
+ $id = "w" . ($id_offset + count($or_with));
+ $or_with[] = "
+ " . $id . " (" . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank) AS (
+ SELECT
+ " . $this->_formatPrimarykeys($table_config['primarykey'], $word_from) . ",
+ " . $word_rank . $rank . " AS rank
+ FROM " . $word_from . "
+ " . $word_join . "
+ " . $where . "
+ )";
+ $current_select = $id;
+ }
+ }
+ if ($skip)
+ break;
+ $count += count($words);
+ }
+
+ if (!$count || !$current_select)
+ continue;
+
+ $or_select[] = "
+ SELECT " . $this->_formatPrimarykeys($table_config['primarykey']) . ", rank / " . $count . " AS rank FROM " . $current_select;
+ }
+
+ if ($or_with[0] === "RECURSIVE")
+ {
+ if (empty($sqlWith) || $sqlWith[0] !== "RECURSIVE")
+ array_unshift($sqlWith, "RECURSIVE");
+ array_shift($or_with);
+ }
+
+ $sqlWith = array_merge($sqlWith, $or_with);
+ $sql_select = array_merge($sql_select, $or_select);
+ $id_offset += count($or_with);
+ }
+
+ return $sql_select;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Private methods
+
+ /**
+ * Checks if the field is not one of the primarykeys.
+ *
+ * @param string $field
+ * @param array|string $primarykeys
+ *
+ * @return boolean
+ */
+ private function _needBasicTableJoin($field, $primarykeys)
+ {
+ if (!is_array($primarykeys) && strpos($primarykeys, ",") !== false) {
+ return $field != $primarykeys;
+ }
+ if (!is_array($primarykeys))
+ $primarykeys = explode(",", $primarykeys);
+
+ foreach ($primarykeys as $key) {
+ if ($field == trim($key))
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns comma separated primarykeys. Optionally with table prefix
+ *
+ * @param array|string $primarykeys
+ * @param string $prefix
+ *
+ * @return string
+ */
+ private function _formatPrimarykeys($primarykeys, $prefix = "")
+ {
+ if (is_array($primarykeys)) {
+ if ($prefix)
+ $prefix .= ".";
+ return $prefix . implode(", " . $prefix, $primarykeys);
+ }
+ if (!$prefix)
+ return $primarykeys;
+
+ return $prefix . "." . implode(", " . $prefix . ".", explode(",", $primarykeys));
+ }
+
+ /**
+ * Adds the prepare statement to the sqlWith stack and handles the
+ * "RECURSIVE" modifier
+ *
+ * @param array &$sqlWith
+ * @param array $prepares
+ *
+ * @return void
+ */
+ private function _addPreparesToSqlWith(&$sqlWith, $prepares)
+ {
+ $recursive = $sqlWith[0] ?? "" === "RECURSIVE";
+ if (!is_array($prepares))
+ $prepares = [$prepares];
+
+ foreach ($prepares as $prep) {
+ $prep = trim($prep);
+ if (strtoupper(substr($prep, 0, 10)) === "RECURSIVE ") {
+ $recursive = true;
+ $sqlWith[] = substr($prep, 10);
+ } else {
+ $sqlWith[] = $prep;
+ }
+ }
+ if ($recursive && $sqlWith[0] !== "RECURSIVE") {
+ array_unshift($sqlWith, "RECURSIVE");
+ }
+ }
+
+ /**
+ * Checks if an array has at least on non numeric value.
+ *
+ * @param array $arr
+ *
+ * @return boolean
+ */
+ private function _hasAtLeastOneNaN($arr)
+ {
+ foreach ($arr as $value)
+ if (!is_numeric($value))
+ return true;
+ return false;
+ }
+
+ /**
+ * Helper function for getDynamicSearchSql
+ *
+ * @param array $join
+ * @param string $prefix
+ *
+ * @return string
+ */
+ private function _makeJoin($join, $prefix = "")
+ {
+ if (!is_array($join))
+ return "";
+ if (!isset($join['table'])) {
+ $output = [];
+ foreach ($join as $j)
+ $output[] = trim($this->_makeJoin($j, $prefix));
+ return implode(" ", $output);
+ }
+ if (!isset($join['on']) && !isset($join['using']) && !isset($join['primarykey']))
+ return "";
+ $output = $prefix . " JOIN " . $join['table'];
+
+ if (isset($join['using']))
+ return $output . " USING (" . $join['using'] . ")";
+
+ if (isset($join['primarykey']))
+ return $output . " USING (" . $join['primarykey'] . ")";
+
+ return $output . " ON (" . $join['on'] . ")";
+ }
+
+ /**
+ * Helper function for _makeRank, _makeCompare and _makeCompareBool
+ *
+ * @param string $function
+ * @param string $mode
+ * @param string $field
+ * @param string $word
+ *
+ * @return string
+ */
+ private function _makeFunction($function, $mode, $field, $word)
+ {
+ $searchfunction = $this->_ci->config->item($mode, 'searchfunctions');
+
+ if (!$searchfunction)
+ return "";
+ $tpl = $searchfunction[$function] ?? "";
+
+ if (strstr($tpl, '{field}'))
+ $tpl = str_replace('{field}', $field, $tpl);
+
+ if (strstr($tpl, '{word}'))
+ $tpl = str_replace('{word}', $this->_ci->db->escape($word), $tpl);
+ if (strstr($tpl, '{like:word}'))
+ $tpl = str_replace('{like:word}', "'%" . $this->_ci->db->escape_like_str($word) . "%'", $tpl);
+
+ return $tpl;
+ }
+
+ /**
+ * Helper function for getDynamicSearchSql
+ *
+ * @param string $mode
+ * @param string $field
+ * @param string $word
+ *
+ * @return string
+ */
+ private function _makeRank($mode, $field, $word)
+ {
+ return $this->_makeFunction('rank', $mode, $field, $word);
+ }
+
+ /**
+ * Helper function for getDynamicSearchSql
+ *
+ * @param string $mode
+ * @param string $field
+ * @param string $word
+ *
+ * @return string
+ */
+ private function _makeCompare($mode, $field, $word)
+ {
+ return $this->_makeFunction('compare', $mode, $field, $word);
+ }
+
+ /**
+ * Helper function for getDynamicSearchSql
+ *
+ * @param string $mode
+ * @param string $field
+ * @param string $word
+ *
+ * @return string
+ */
+ private function _makeCompareBool($mode, $field, $word)
+ {
+ $searchfunction = $this->_ci->config->item($mode, 'searchfunctions');
+
+ if (!$searchfunction)
+ return "";
+ $function = isset($searchfunction['compare_boolean']) ? 'compare_boolean' : 'compare';
+ return $this->_makeFunction($function, $mode, $field, $word);
+ }
+
+ /**
+ * Converts the search string to an array.
+ * First level should be joined with an OR.
+ * Second level should be joined with an AND or AND NOT.
+ * It is an associative array where the key is a code for the field
+ * which the words should be compared with and the value is the array
+ * of words.
+ * Use AND NOT if the first letter in the key is "!".
+ * Use AND if the first letter in the key is not "!".
+ * E.g:
+ * If the key is:
+ * "": the words should be compared to all fields with AND.
+ * "!": the words should be compared to all fields with AND NOT.
+ * "somefield": the words should be compared to the field somefield with
+ * AND.
+ * "!somefield": the words should be compared to the field somefield with
+ * AND NOT.
+ *
+ * @param string $searchstring
+ * @param array $types
+ *
+ * @return array
+ */
+ private function _convertQuery($searchstring, $types)
+ {
+ $searchAllTypes = count($types) == count($this->_ci->config->item('search'));
+ $allowedTypes = array_keys($types);
+
+ $currentArray = [];
+ $outputArray = [];
+ $cleanStrings = [];
+ $cleanSearchstring = '';
+ $filter = ['+' => [], '-' => []];
+ $typeAliases = [];
+
+ $tmp = explode(' ', strtolower($searchstring));
+ while ($tmp) {
+ $chunk = trim(array_shift($tmp));
+ if ($chunk == '')
+ continue;
+
+ if (strpos($chunk, '"') !== false) {
+ $test = explode('"', $chunk);
+ if (count($test) > 2) {
+ $rest = implode('"', array_slice($test, 2));
+ if ($rest) {
+ array_unshift($tmp, $rest);
+ $chunk = implode('"', array_slice($test, 0, 2)) . '"';
+ }
+ }
+ if (count($test) == 2) {
+ while ($tmp && strpos($test[1], '"') === false) {
+ $test[1] .= ' ' . trim(array_shift($tmp));
+ }
+ if (strpos($test[1], '"') === false) {
+ $chunk = implode('"', $test) . '"';
+ } else {
+ $test2 = explode('"', $test[1], 2);
+ $chunk = $test[0] . '"' . $test2[0] . '"';
+ if ($test2[1]) {
+ array_unshift($tmp, $test2[1]);
+ }
+ }
+ }
+ if (strpos($chunk, ' ') === false) {
+ $chunk = str_replace('"', '', $chunk);
+ }
+ }
+
+ if ($chunk == 'or') {
+ $this->_convertQueryCleanupOr($currentArray, $cleanStrings, $filter, $searchAllTypes, $allowedTypes);
+ $filter = ['+' => [], '-' => []];
+ if ($currentArray) {
+ $cleanSearchstring .= ($cleanSearchstring ? ' or ' : '') . implode(' ', $cleanStrings);
+ $cleanStrings = [];
+ $outputArray[] = $currentArray;
+ $currentArray = [];
+ }
+ continue;
+ }
+
+ if ($chunk == ':' || $chunk == '-' || substr($chunk, -1) == ':')
+ continue;
+
+ if ($chunk[0] == ':' || ($chunk[0] == '-' && $chunk[1] == ':')) {
+ if (!$typeAliases) {
+ foreach ($types as $type => $config) {
+ $typeAliases[$type] = $type;
+ if (isset($config['alias'])) {
+ foreach ($config['alias'] as $alias) {
+ if (!isset($typeAliases[$alias]))
+ $typeAliases[$alias] = $type;
+ }
+ }
+ }
+ }
+
+ $test = explode(':', $chunk, 2);
+ if (isset($typeAliases[$test[1]]))
+ $chunk = $test[0] . ':' . $typeAliases[$test[1]];
+ elseif ($test[0] == '-')
+ continue;
+ }
+
+ if (in_array($chunk, $cleanStrings))
+ continue;
+
+ $cleanStrings[] = $chunk;
+
+ $chunk = str_replace('"', '', $chunk);
+ $code = '';
+
+ if ($chunk[0] == '-') {
+ $code = '!';
+ $chunk = substr($chunk, 1);
+ }
+ if (strpos($chunk, ':') !== false) {
+ $chunk = explode(':', $chunk, 2);
+ if (!$chunk[0]) {
+ $filter[$code ? '-' : '+'][] = $chunk[1];
+ continue;
+ }
+ $code .= $chunk[0];
+ $chunk = $chunk[1];
+ }
+
+ if (!isset($currentArray[$code]))
+ $currentArray[$code] = [];
+
+ $currentArray[$code][] = $chunk;
+ }
+
+ $this->_convertQueryCleanupOr($currentArray, $cleanStrings, $filter, $searchAllTypes, $allowedTypes);
+ if ($currentArray) {
+ $cleanSearchstring .= ($cleanSearchstring ? ' or ' : '') . implode(' ', $cleanStrings);
+ $outputArray[] = $currentArray;
+ }
+ return [$outputArray, $cleanSearchstring];
+ }
+
+ private function _convertQueryCleanupOr(&$currentArray, &$cleanStrings, $filter, $searchAllTypes, $allowedTypes)
+ {
+ if ($filter['+'] && $filter['-']) {
+ $double = array_intersect($filter['+'], $filter['-']);
+ if ($double) {
+ foreach ($double as $type) {
+ array_splice($cleanStrings, array_search(':' . $type, $cleanStrings), 1);
+ array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
+ }
+ $filter['+'] = array_diff($filter['+'], $double);
+ $filter['-'] = array_diff($filter['-'], $double);
+ }
+ if (!$filter['+'] && !$filter['-']) {
+ // All filters cancel each other out
+ $currentArray = [];
+ $cleanStrings = [];
+ return;
+ }
+ if ($filter['+']) {
+ foreach ($filter['-'] as $type) {
+ array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
+ }
+ $filter['-'] = [];
+ }
+ }
+ if ($filter['+']) {
+ $cleanFilter = array_intersect($allowedTypes, $filter['+']);
+ if (!$cleanFilter) {
+ // All filters are forbidden
+ $currentArray = [];
+ $cleanStrings = [];
+ return;
+ }
+ $forbiddenFilter = array_diff($cleanFilter, $filter['+']);
+ foreach ($forbiddenFilter as $type) {
+ array_splice($cleanStrings, array_search(':' . $type, $cleanStrings), 1);
+ }
+ $filter['+'] = $cleanFilter;
+ } elseif ($filter['-']) {
+ $filter['+'] = array_diff($allowedTypes, $filter['-']);
+ if (!$searchAllTypes) {
+ foreach ($filter['+'] as $type)
+ $cleanStrings[] = ':' . $type;
+ foreach ($filter['-'] as $type)
+ array_splice($cleanStrings, array_search('-:' . $type, $cleanStrings), 1);
+ }
+ } else {
+ if (!$searchAllTypes) {
+ foreach ($allowedTypes as $type)
+ $cleanStrings[] = ':' . $type;
+ }
+ }
+
+ if ($filter['+']) {
+ $currentArray['-filter'] = $filter['+'];
+ }
+ }
+}
diff --git a/public/css/components/searchbar/searchbar.css b/public/css/components/searchbar/searchbar.css
index 867273962..09da19625 100644
--- a/public/css/components/searchbar/searchbar.css
+++ b/public/css/components/searchbar/searchbar.css
@@ -14,6 +14,35 @@
--fhc-searchbar-results-btn-color: var(--bs-btn-color, white);
}
+.searchbar_input_clear {
+ /* 1rem * 5 / 8 = width of icon */
+ margin-inline-start: calc(-1rem * 5 / 8 - var(--bs-btn-padding-x) * 2 - var(--bs-border-width)) !important;
+ border: 0;
+ margin-inline-end: 1px;
+ z-index: 1;
+}
+.searchbar_input_clear:hover,
+.searchbar_input_clear:focus {
+ color: var(--bs-body-color);
+ background-color: transparent;
+}
+.searchbar_input:focus,
+.searchbar_input:focus ~ .searchbar_input_clear {
+ z-index: 5; /* same as bootstrap .input-group > .form-control:focus */
+}
+
+.searchbar_setting_btn {
+ --bs-btn-color: var(--bs-body-color);
+ --bs-btn-bg: var(--bs-tertiary-bg);
+ --bs-btn-border-color: var(--bs-border-color);
+}
+
+
+.searchbar_searchbox.open {
+ z-index: 10000;
+}
+
+
.searchbar_settings {
position: absolute;
z-index: 9999;
@@ -120,6 +149,76 @@
list-style: none;
}
+/* new variant with template/frame */
+
+.searchbar-result-template-frame {
+ border-bottom: 1px solid lightgrey;
+ margin-bottom: 1rem;
+ padding-bottom: 1rem;
+}
+
+.searchbar-result-template-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .5rem;
+ padding: 0;
+ margin: 1rem 0 0;
+}
+
+.searchbar-square-image {
+ position: relative;
+ display: block;
+ height: 0;
+ padding-bottom: 100%;
+}
+.searchbar-square-image > * {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+}
+.searchbar-square-image > img {
+ object-fit: cover;
+}
+.searchbar-square-image > div {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ color: rgba(var(--bs-white-rgb),1);
+ background-color: rgba(var(--bs-primary-rgb), 1);
+}
+.searchbar-square-image.rounded-circle > img,
+.searchbar-square-image.rounded-circle > div {
+ border-radius: 50%;
+}
+
+.searchbar-rounded-image {
+ display: block;
+ width: 100%;
+}
+.searchbar-rounded-image > img {
+ width: 100%;
+}
+.searchbar-rounded-image > div {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ color: rgba(var(--bs-white-rgb),1);
+ background-color: rgba(var(--bs-primary-rgb), 1);
+ height: calc((100px - .75em) * 1.3);
+}
+.searchbar-rounded-image > img,
+.searchbar-rounded-image > div {
+ border-radius: .25rem;
+}
+
+.no-margin-paragraphs p {
+ margin: 0;
+}
+
.non-selectable {
user-select: none;
-webkit-user-select: none; /* Safari */
@@ -129,4 +228,4 @@
.searchbar_inaktiv {
opacity: .6;
-}
\ No newline at end of file
+}
diff --git a/public/js/api/factory/language.js b/public/js/api/factory/language.js
new file mode 100644
index 000000000..8175ccc0e
--- /dev/null
+++ b/public/js/api/factory/language.js
@@ -0,0 +1,25 @@
+/**
+ * Copyright (C) 2025 fhcomplete.org
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see