. */ declare(strict_types=1); namespace Fisharebest\Webtrees\Module; use Fisharebest\Localization\Locale\LocaleInterface; use Fisharebest\Webtrees\Auth; use Fisharebest\Webtrees\Contracts\UserInterface; use Fisharebest\Webtrees\Family; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Individual; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\LocalizationService; use Fisharebest\Webtrees\Session; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Validator; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use function app; use function array_keys; use function assert; use function e; use function implode; use function in_array; use function ob_get_clean; use function ob_start; use function redirect; use function route; use function view; /** * Class IndividualListModule */ class IndividualListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface { use ModuleListTrait; protected const ROUTE_URL = '/tree/{tree}/individual-list'; private LocalizationService $localization_service; /** * IndividualListModule constructor. * * @param LocalizationService $localization_service */ public function __construct(LocalizationService $localization_service) { $this->localization_service = $localization_service; } /** * Initialization. * * @return void */ public function boot(): void { Registry::routeFactory()->routeMap() ->get(static::class, static::ROUTE_URL, $this); } /** * How should this module be identified in the control panel, etc.? * * @return string */ public function title(): string { /* I18N: Name of a module/list */ return I18N::translate('Individuals'); } /** * A sentence describing what this module does. * * @return string */ public function description(): string { /* I18N: Description of the “Individuals” module */ return I18N::translate('A list of individuals.'); } /** * CSS class for the URL. * * @return string */ public function listMenuClass(): string { return 'menu-list-indi'; } /** * @param Tree $tree * @param array|null> $parameters * * @return string */ public function listUrl(Tree $tree, array $parameters = []): string { $request = app(ServerRequestInterface::class); assert($request instanceof ServerRequestInterface); $xref = Validator::attributes($request)->isXref()->string('xref', ''); if ($xref !== '') { $individual = Registry::individualFactory()->make($xref, $tree); if ($individual instanceof Individual && $individual->canShow()) { $primary_name = $individual->getPrimaryName(); $parameters['surname'] = $parameters['surname'] ?? $individual->getAllNames()[$primary_name]['surn'] ?? null; } } $parameters['tree'] = $tree->name(); return route(static::class, $parameters); } /** * @return array */ public function listUrlAttributes(): array { return []; } /** * Handle URLs generated by older versions of webtrees * * @param ServerRequestInterface $request * * @return ResponseInterface */ public function getListAction(ServerRequestInterface $request): ResponseInterface { $tree = Validator::attributes($request)->tree(); return redirect($this->listUrl($tree, $request->getQueryParams())); } /** * @param ServerRequestInterface $request * * @return ResponseInterface */ public function handle(ServerRequestInterface $request): ResponseInterface { $tree = Validator::attributes($request)->tree(); $user = Validator::attributes($request)->user(); Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user); return $this->createResponse($tree, $user, $request->getQueryParams(), false); } /** * @param Tree $tree * @param UserInterface $user * @param array $params * @param bool $families * * @return ResponseInterface */ protected function createResponse(Tree $tree, UserInterface $user, array $params, bool $families): ResponseInterface { ob_start(); // We show three different lists: initials, surnames and individuals // All surnames beginning with this letter where "@"=unknown and ","=none $alpha = $params['alpha'] ?? ''; // All individuals with this surname $surname = $params['surname'] ?? ''; // All individuals $show_all = $params['show_all'] ?? 'no'; // Long lists can be broken down by given name $show_all_firstnames = $params['show_all_firstnames'] ?? 'no'; if ($show_all_firstnames === 'yes') { $falpha = ''; } else { // All first names beginning with this letter $falpha = $params['falpha'] ?? ''; } $show_marnm = $params['show_marnm'] ?? ''; switch ($show_marnm) { case 'no': case 'yes': $user->setPreference($families ? 'family-list-marnm' : 'individual-list-marnm', $show_marnm); break; default: $show_marnm = $user->getPreference($families ? 'family-list-marnm' : 'individual-list-marnm'); } // Make sure selections are consistent. // i.e. can’t specify show_all and surname at the same time. if ($show_all === 'yes') { if ($show_all_firstnames === 'yes') { $alpha = ''; $surname = ''; $legend = I18N::translate('All'); $params = [ 'tree' => $tree->name(), 'show_all' => 'yes', ]; $show = 'indi'; } elseif ($falpha !== '') { $alpha = ''; $surname = ''; $legend = I18N::translate('All') . ', ' . e($falpha) . '…'; $params = [ 'tree' => $tree->name(), 'show_all' => 'yes', ]; $show = 'indi'; } else { $alpha = ''; $surname = ''; $legend = I18N::translate('All'); $show = $params['show'] ?? 'surn'; $params = [ 'tree' => $tree->name(), 'show_all' => 'yes', ]; } } elseif ($surname !== '') { $alpha = $this->localization_service->initialLetter($surname, I18N::locale()); // so we can highlight the initial letter $show_all = 'no'; if ($surname === Individual::NOMEN_NESCIO) { $legend = I18N::translateContext('Unknown surname', '…'); } else { // The surname parameter is a root/canonical form. // Display it as the actual surname $legend = implode('/', array_keys($this->surnames($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale()))); } $params = [ 'tree' => $tree->name(), 'surname' => $surname, 'falpha' => $falpha, ]; switch ($falpha) { case '': break; case '@': $legend .= ', ' . I18N::translateContext('Unknown given name', '…'); break; default: $legend .= ', ' . e($falpha) . '…'; break; } $show = 'indi'; // SURN list makes no sense here } elseif ($alpha === '@') { $show_all = 'no'; $legend = I18N::translateContext('Unknown surname', '…'); $params = [ 'alpha' => $alpha, 'tree' => $tree->name(), ]; $show = 'indi'; // SURN list makes no sense here } elseif ($alpha === ',') { $show_all = 'no'; $legend = I18N::translate('None'); $params = [ 'alpha' => $alpha, 'tree' => $tree->name(), ]; $show = 'indi'; // SURN list makes no sense here } elseif ($alpha !== '') { $show_all = 'no'; $legend = e($alpha) . '…'; $show = $params['show'] ?? 'surn'; $params = [ 'alpha' => $alpha, 'tree' => $tree->name(), ]; } else { $show_all = 'no'; $legend = '…'; $params = [ 'tree' => $tree->name(), ]; $show = 'none'; // Don't show lists until something is chosen } $legend = '' . $legend . ''; if ($families) { $title = I18N::translate('Families') . ' — ' . $legend; } else { $title = I18N::translate('Individuals') . ' — ' . $legend; } ?>
    surnameAlpha($tree, $show_marnm === 'yes', $families, I18N::locale()) as $letter => $count) : ?>
  • 0) : ?> surnameInitial((string) $letter) ?> surnameInitial((string) $letter) ?>

surnames($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale()); if ($show === 'surn') { // Show the surname list switch ($tree->getPreference('SURNAME_LIST_STYLE')) { case 'style1': echo view('lists/surnames-column-list', [ 'module' => $this, 'surnames' => $surns, 'totals' => true, 'tree' => $tree, ]); break; case 'style3': echo view('lists/surnames-tag-cloud', [ 'module' => $this, 'surnames' => $surns, 'totals' => true, 'tree' => $tree, ]); break; case 'style2': default: echo view('lists/surnames-table', [ 'families' => $families, 'module' => $this, 'order' => [[1, 'desc']], 'surnames' => $surns, 'tree' => $tree, ]); break; } } else { // Show the list $count = 0; foreach ($surns as $surnames) { foreach ($surnames as $total) { $count += $total; } } // Don't sublist short lists. if ($count < $tree->getPreference('SUBLIST_TRIGGER_I')) { $falpha = ''; } else { $givn_initials = $this->givenAlpha($tree, $surname, $alpha, $show_marnm === 'yes', $families, I18N::locale()); // Break long lists by initial letter of given name if ($surname !== '' || $show_all === 'yes') { if ($show_all === 'no') { echo '

', I18N::translate('Individuals with surname %s', $legend), '

'; } // Don't show the list until we have some filter criteria $show = $falpha !== '' || $show_all_firstnames === 'yes' ? 'indi' : 'none'; $list = []; echo ''; echo '

', implode(' | ', $list), '

'; } } if ($show === 'indi') { if (!$families) { echo view('lists/individuals-table', [ 'individuals' => $this->individuals($tree, $surname, $alpha, $falpha, $show_marnm === 'yes', false, I18N::locale()), 'sosa' => false, 'tree' => $tree, ]); } else { echo view('lists/families-table', [ 'families' => $this->families($tree, $surname, $alpha, $falpha, $show_marnm === 'yes', I18N::locale()), 'tree' => $tree, ]); } } } } ?>
viewResponse('modules/individual-list/page', [ 'content' => $html, 'title' => $title, 'tree' => $tree, ]); } /** * Some initial letters have a special meaning * * @param string $initial * * @return string */ protected function givenNameInitial(string $initial): string { if ($initial === '@') { return I18N::translateContext('Unknown given name', '…'); } return e($initial); } /** * Some initial letters have a special meaning * * @param string $initial * * @return string */ protected function surnameInitial(string $initial): string { if ($initial === '@') { return I18N::translateContext('Unknown surname', '…'); } if ($initial === ',') { return I18N::translate('None'); } return e($initial); } /** * Restrict a query to individuals that are a spouse in a family record. * * @param bool $fams * @param Builder $query */ protected function whereFamily(bool $fams, Builder $query): void { if ($fams) { $query->join('link', static function (JoinClause $join): void { $join ->on('l_from', '=', 'n_id') ->on('l_file', '=', 'n_file') ->where('l_type', '=', 'FAMS'); }); } } /** * Restrict a query to include/exclude married names. * * @param bool $marnm * @param Builder $query */ protected function whereMarriedName(bool $marnm, Builder $query): void { if (!$marnm) { $query->where('n_type', '<>', '_MARNM'); } } /** * Get a list of initial surname letters. * * @param Tree $tree * @param bool $marnm if set, include married names * @param bool $fams if set, only consider individuals with FAMS records * @param LocaleInterface $locale * * @return array */ public function surnameAlpha(Tree $tree, bool $marnm, bool $fams, LocaleInterface $locale): array { $collation = $this->localization_service->collation($locale); $n_surn = $this->fieldWithCollation('n_surn', $collation); $alphas = []; $query = DB::table('name')->where('n_file', '=', $tree->id()); $this->whereFamily($fams, $query); $this->whereMarriedName($marnm, $query); // Fetch all the letters in our alphabet, whether or not there // are any names beginning with that letter. It looks better to // show the full alphabet, rather than omitting rare letters such as X. foreach ($this->localization_service->alphabet($locale) as $letter) { $query2 = clone $query; $this->whereInitial($query2, 'n_surn', $letter, $locale); $alphas[$letter] = $query2->count(); } // Now fetch initial letters that are not in our alphabet, // including "@" (for "@N.N.") and "" for no surname. foreach ($this->localization_service->alphabet($locale) as $letter) { $query->where($n_surn, 'NOT LIKE', $letter . '%'); } $rows = $query ->groupBy(['initial']) ->orderBy('initial') ->pluck(new Expression('COUNT(*) AS aggregate'), new Expression('SUBSTR(n_surn, 1, 1) AS initial')); $specials = ['@', '']; foreach ($rows as $alpha => $count) { if (!in_array($alpha, $specials, true)) { $alphas[$alpha] = (int) $count; } } // Empty surnames have a special code ',' - as we search for SURN.GIVN foreach ($specials as $special) { if ($rows->has($special)) { $alphas[$special ?: ','] = (int) $rows[$special]; } } return $alphas; } /** * Get a list of initial given name letters for indilist.php and famlist.php * * @param Tree $tree * @param string $surn if set, only consider people with this surname * @param string $salpha if set, only consider surnames starting with this letter * @param bool $marnm if set, include married names * @param bool $fams if set, only consider individuals with FAMS records * @param LocaleInterface $locale * * @return array */ public function givenAlpha(Tree $tree, string $surn, string $salpha, bool $marnm, bool $fams, LocaleInterface $locale): array { $collation = $this->localization_service->collation($locale); $alphas = []; $query = DB::table('name') ->where('n_file', '=', $tree->id()); $this->whereFamily($fams, $query); $this->whereMarriedName($marnm, $query); if ($surn !== '') { $n_surn = $this->fieldWithCollation('n_surn', $collation); $query->where($n_surn, '=', $surn); } elseif ($salpha === ',') { $query->where('n_surn', '=', ''); } elseif ($salpha === '@') { $query->where('n_surn', '=', Individual::NOMEN_NESCIO); } elseif ($salpha !== '') { $this->whereInitial($query, 'n_surn', $salpha, $locale); } else { // All surnames $query->whereNotIn('n_surn', ['', Individual::NOMEN_NESCIO]); } // Fetch all the letters in our alphabet, whether or not there // are any names beginning with that letter. It looks better to // show the full alphabet, rather than omitting rare letters such as X foreach ($this->localization_service->alphabet($locale) as $letter) { $query2 = clone $query; $this->whereInitial($query2, 'n_givn', $letter, $locale); $alphas[$letter] = $query2->distinct()->count('n_id'); } $rows = $query ->groupBy(['initial']) ->orderBy('initial') ->pluck(new Expression('COUNT(*) AS aggregate'), new Expression('UPPER(SUBSTR(n_givn, 1, 1)) AS initial')); foreach ($rows as $alpha => $count) { if ($alpha !== '@') { $alphas[$alpha] = (int) $count; } } if ($rows->has('@')) { $alphas['@'] = (int) $rows['@']; } return $alphas; } /** * Get a count of actual surnames and variants, based on a "root" surname. * * @param Tree $tree * @param string $surn if set, only count people with this surname * @param string $salpha if set, only consider surnames starting with this letter * @param bool $marnm if set, include married names * @param bool $fams if set, only consider individuals with FAMS records * @param LocaleInterface $locale * * @return array> */ protected function surnames( Tree $tree, string $surn, string $salpha, bool $marnm, bool $fams, LocaleInterface $locale ): array { $collation = $this->localization_service->collation($locale); $query = DB::table('name') ->where('n_file', '=', $tree->id()) ->select([ new Expression('UPPER(n_surn /*! COLLATE ' . $collation . ' */) AS n_surn'), new Expression('n_surname /*! COLLATE utf8_bin */ AS n_surname'), new Expression('COUNT(*) AS total'), ]); $this->whereFamily($fams, $query); $this->whereMarriedName($marnm, $query); if ($surn !== '') { $query->where('n_surn', '=', $surn); } elseif ($salpha === ',') { $query->where('n_surn', '=', ''); } elseif ($salpha === '@') { $query->where('n_surn', '=', Individual::NOMEN_NESCIO); } elseif ($salpha !== '') { $this->whereInitial($query, 'n_surn', $salpha, $locale); } else { // All surnames $query->whereNotIn('n_surn', ['', Individual::NOMEN_NESCIO]); } $query ->groupBy(['n_surn']) ->groupBy(['n_surname']) ->orderBy('n_surname'); $list = []; foreach ($query->get() as $row) { $list[$row->n_surn][$row->n_surname] = (int) $row->total; } return $list; } /** * Fetch a list of individuals with specified names * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" * To search for names with no surnames, use $salpha="," * * @param Tree $tree * @param string $surn if set, only fetch people with this surname * @param string $salpha if set, only fetch surnames starting with this letter * @param string $galpha if set, only fetch given names starting with this letter * @param bool $marnm if set, include married names * @param bool $fams if set, only fetch individuals with FAMS records * @param LocaleInterface $locale * * @return Collection */ protected function individuals( Tree $tree, string $surn, string $salpha, string $galpha, bool $marnm, bool $fams, LocaleInterface $locale ): Collection { $collation = $this->localization_service->collation($locale); // Use specific collation for name fields. $n_givn = $this->fieldWithCollation('n_givn', $collation); $n_surn = $this->fieldWithCollation('n_surn', $collation); $query = DB::table('individuals') ->join('name', static function (JoinClause $join): void { $join ->on('n_id', '=', 'i_id') ->on('n_file', '=', 'i_file'); }) ->where('i_file', '=', $tree->id()) ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'n_givn', 'n_surn']); $this->whereFamily($fams, $query); $this->whereMarriedName($marnm, $query); if ($surn) { $query->where($n_surn, '=', $surn); } elseif ($salpha === ',') { $query->where($n_surn, '=', ''); } elseif ($salpha === '@') { $query->where($n_surn, '=', Individual::NOMEN_NESCIO); } elseif ($salpha) { $this->whereInitial($query, 'n_surn', $salpha, $locale); } else { // All surnames $query->whereNotIn($n_surn, ['', Individual::NOMEN_NESCIO]); } if ($galpha) { $this->whereInitial($query, 'n_givn', $galpha, $locale); } $query ->orderBy(new Expression("CASE n_surn WHEN '" . Individual::NOMEN_NESCIO . "' THEN 1 ELSE 0 END")) ->orderBy($n_surn) ->orderBy(new Expression("CASE n_givn WHEN '" . Individual::NOMEN_NESCIO . "' THEN 1 ELSE 0 END")) ->orderBy($n_givn); $individuals = new Collection(); $rows = $query->get(); foreach ($rows as $row) { $individual = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); assert($individual instanceof Individual); // The name from the database may be private - check the filtered list... foreach ($individual->getAllNames() as $n => $name) { if ($name['givn'] === $row->n_givn && $name['surn'] === $row->n_surn) { $individual->setPrimaryName($n); // We need to clone $individual, as we may have multiple references to the // same individual in this list, and the "primary name" would otherwise // be shared amongst all of them. $individuals->push(clone $individual); break; } } } return $individuals; } /** * Fetch a list of families with specified names * To search for unknown names, use $surn="@N.N.", $salpha="@" or $galpha="@" * To search for names with no surnames, use $salpha="," * * @param Tree $tree * @param string $surn if set, only fetch people with this surname * @param string $salpha if set, only fetch surnames starting with this letter * @param string $galpha if set, only fetch given names starting with this letter * @param bool $marnm if set, include married names * @param LocaleInterface $locale * * @return Collection */ protected function families(Tree $tree, string $surn, string $salpha, string $galpha, bool $marnm, LocaleInterface $locale): Collection { $families = new Collection(); foreach ($this->individuals($tree, $surn, $salpha, $galpha, $marnm, true, $locale) as $indi) { foreach ($indi->spouseFamilies() as $family) { $families->push($family); } } return $families->unique(); } /** * Use MySQL-specific comments so we can run these queries on other RDBMS. * * @param string $field * @param string $collation * * @return Expression */ protected function fieldWithCollation(string $field, string $collation): Expression { return new Expression($field . ' /*! COLLATE ' . $collation . ' */'); } /** * Modify a query to restrict a field to a given initial letter. * Take account of digraphs, equialent letters, etc. * * @param Builder $query * @param string $field * @param string $letter * @param LocaleInterface $locale * * @return void */ protected function whereInitial( Builder $query, string $field, string $letter, LocaleInterface $locale ): void { $collation = $this->localization_service->collation($locale); // Use MySQL-specific comments so we can run these queries on other RDBMS. $field_with_collation = $this->fieldWithCollation($field, $collation); switch ($locale->languageTag()) { case 'cs': $this->whereInitialCzech($query, $field_with_collation, $letter); break; case 'da': case 'nb': case 'nn': $this->whereInitialNorwegian($query, $field_with_collation, $letter); break; case 'sv': case 'fi': $this->whereInitialSwedish($query, $field_with_collation, $letter); break; case 'hu': $this->whereInitialHungarian($query, $field_with_collation, $letter); break; case 'nl': $this->whereInitialDutch($query, $field_with_collation, $letter); break; default: $query->where($field_with_collation, 'LIKE', '\\' . $letter . '%'); } } /** * @param Builder $query * @param Expression $field * @param string $letter */ protected function whereInitialCzech(Builder $query, Expression $field, string $letter): void { if ($letter === 'C') { $query->where($field, 'LIKE', 'C%')->where($field, 'NOT LIKE', 'CH%'); } else { $query->where($field, 'LIKE', '\\' . $letter . '%'); } } /** * @param Builder $query * @param Expression $field * @param string $letter */ protected function whereInitialDutch(Builder $query, Expression $field, string $letter): void { if ($letter === 'I') { $query->where($field, 'LIKE', 'I%')->where($field, 'NOT LIKE', 'IJ%'); } else { $query->where($field, 'LIKE', '\\' . $letter . '%'); } } /** * Hungarian has many digraphs and trigraphs, so exclude these from prefixes. * * @param Builder $query * @param Expression $field * @param string $letter */ protected function whereInitialHungarian(Builder $query, Expression $field, string $letter): void { switch ($letter) { case 'C': $query->where($field, 'LIKE', 'C%')->where($field, 'NOT LIKE', 'CS%'); break; case 'D': $query->where($field, 'LIKE', 'D%')->where($field, 'NOT LIKE', 'DZ%'); break; case 'DZ': $query->where($field, 'LIKE', 'DZ%')->where($field, 'NOT LIKE', 'DZS%'); break; case 'G': $query->where($field, 'LIKE', 'G%')->where($field, 'NOT LIKE', 'GY%'); break; case 'L': $query->where($field, 'LIKE', 'L%')->where($field, 'NOT LIKE', 'LY%'); break; case 'N': $query->where($field, 'LIKE', 'N%')->where($field, 'NOT LIKE', 'NY%'); break; case 'S': $query->where($field, 'LIKE', 'S%')->where($field, 'NOT LIKE', 'SZ%'); break; case 'T': $query->where($field, 'LIKE', 'T%')->where($field, 'NOT LIKE', 'TY%'); break; case 'Z': $query->where($field, 'LIKE', 'Z%')->where($field, 'NOT LIKE', 'ZS%'); break; default: $query->where($field, 'LIKE', '\\' . $letter . '%'); break; } } /** * In Norwegian and Danish, AA gets listed under Å, NOT A * * @param Builder $query * @param Expression $field * @param string $letter */ protected function whereInitialNorwegian(Builder $query, Expression $field, string $letter): void { switch ($letter) { case 'A': $query->where($field, 'LIKE', 'A%')->where($field, 'NOT LIKE', 'AA%'); break; case 'Å': $query->where(static function (Builder $query) use ($field): void { $query ->where($field, 'LIKE', 'Å%') ->orWhere($field, 'LIKE', 'AA%'); }); break; default: $query->where($field, 'LIKE', '\\' . $letter . '%'); break; } } /** * In Swedish and Finnish, AA gets listed under A, NOT Å (even though Swedish collation says they should). * * @param Builder $query * @param Expression $field * @param string $letter */ protected function whereInitialSwedish(Builder $query, Expression $field, string $letter): void { if ($letter === 'Å') { $query->where($field, 'LIKE', 'Å%')->where($field, 'NOT LIKE', 'AA%'); } else { $query->where($field, 'LIKE', '\\' . $letter . '%'); } } }