1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2017 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16 17namespace Fisharebest\Webtrees\Module; 18 19use Fisharebest\Webtrees\Census\Census; 20use Fisharebest\Webtrees\Census\CensusInterface; 21use Fisharebest\Webtrees\Family; 22use Fisharebest\Webtrees\Filter; 23use Fisharebest\Webtrees\FontAwesome; 24use Fisharebest\Webtrees\Functions\Functions; 25use Fisharebest\Webtrees\Functions\FunctionsDb; 26use Fisharebest\Webtrees\Functions\FunctionsEdit; 27use Fisharebest\Webtrees\GedcomRecord; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Menu; 31use Fisharebest\Webtrees\Note; 32use Fisharebest\Webtrees\Soundex; 33 34/** 35 * Class CensusAssistantModule 36 */ 37class CensusAssistantModule extends AbstractModule { 38 /** {@inheritdoc} */ 39 public function getTitle() { 40 return /* I18N: Name of a module */ 41 I18N::translate('Census assistant'); 42 } 43 44 /** {@inheritdoc} */ 45 public function getDescription() { 46 return /* I18N: Description of the “Census assistant” module */ 47 I18N::translate('An alternative way to enter census transcripts and link them to individuals.'); 48 } 49 50 /** 51 * This is a general purpose hook, allowing modules to respond to routes 52 * of the form module.php?mod=FOO&mod_action=BAR 53 * 54 * @param string $mod_action 55 */ 56 public function modAction($mod_action) { 57 global $WT_TREE; 58 59 switch ($mod_action) { 60 case 'census-header': 61 header('Content-Type: text/html; charset=utf8'); 62 $census = Filter::get('census'); 63 echo $this->censusTableHeader(new $census); 64 break; 65 66 case 'census-individual': 67 header('Content-Type: text/html; charset=utf8'); 68 $census = Filter::get('census'); 69 $individual = Individual::getInstance(Filter::get('xref'), $WT_TREE); 70 $head = Individual::getInstance(Filter::get('head'), $WT_TREE); 71 echo $this->censusTableRow(new $census, $individual, $head); 72 break; 73 74 case 'media_find': 75 self::mediaFind(); 76 break; 77 case 'media_query_3a': 78 self::mediaQuery(); 79 break; 80 default: 81 http_response_code(404); 82 } 83 } 84 85 /** 86 * @param Individual $individual 87 * @param CensusInterface $census 88 */ 89 public function createCensusAssistant(Individual $individual) { 90 ?> 91 92 <div id="census-assistant-link" hidden> 93 <a href="#"> 94 <?= I18N::translate('Create a shared note using the census assistant') ?> 95 </a> 96 </div> 97 98 <div id="census-assistant" hidden> 99 <input type="hidden" name="ca_census" id="ca-census"> 100 <div class="form-group"> 101 <div class="input-group"> 102 <label for="census-assistant-title" class="input-group-addon"> 103 <?= I18N::translate('Title') ?> 104 </label> 105 <input class="form-control" id="ca-title" name="ca_title" value=""> 106 </div> 107 </div> 108 109 <div class="row"> 110 <div class="form-group col-sm-6"> 111 <div class="input-group"> 112 <label for="census-assistant-citation" class="input-group-addon"> 113 <?= I18N::translate('Citation') ?> 114 </label> 115 <input class="form-control" id="census-assistant-citation" name="ca_citation"> 116 </div> 117 </div> 118 119 <div class="form-group col-sm-6"> 120 <div class="input-group"> 121 <label for="census-assistant-place" class="input-group-addon"> 122 <?= I18N::translate('Place') ?> 123 </label> 124 <input class="form-control" id="census-assistant-place" name="ca_place"> 125 </div> 126 </div> 127 </div> 128 129 <div class="form-group"> 130 <div class="input-group"> 131 <span class="input-group-addon"><?= I18N::translate('Individuals') ?></span> 132 <?= FunctionsEdit::formControlIndividual($individual, ['id' => 'census-assistant-individual', 'style' => 'width:100%']) ?> 133 <span class="input-group-btn"> 134 <button type="button" class="btn btn-primary" id="census-assistant-add"> 135 <?= FontAwesome::semanticIcon('add', I18N::translate('Add')) ?> 136 </button> 137 </span> 138 <span class="input-group-btn"> 139 <button type="button" class="btn btn-primary" id="census-assistant-head" 140 title="<?= I18N::translate('Head of household') ?>"> 141 <?= FontAwesome::semanticIcon('individual', I18N::translate('Head of household')) ?> 142 </button> 143 </span> 144 </div> 145 </div> 146 147 <table class="table table-bordered table-small table-responsive wt-census-assistant-table" 148 id="census-assistant-table"> 149 <thead class="wt-census-assistant-header"></thead> 150 <tbody class="wt-census-assistant-body"></tbody> 151 </table> 152 153 <div class="form-group"> 154 <div class="input-group"> 155 <label for="census-assistant-notes" class="input-group-addon"> 156 <?= I18N::translate('Notes') ?> 157 </label> 158 <input class="form-control" id="census-assistant-notes" name="ca_notes"> 159 </div> 160 </div> 161 </div> 162 163 <script> 164 // When a census date/place is selected, activate the census-assistant 165 function censusAssistantSelect () { 166 var censusAssistantLink = document.querySelector('#census-assistant-link'); 167 var censusAssistant = document.querySelector('#census-assistant'); 168 var censusOption = this.options[this.selectedIndex]; 169 var census = censusOption.dataset.census; 170 var censusPlace = censusOption.dataset.place; 171 var censusYear = censusOption.value.substr(-4); 172 173 if (censusOption.value !== '') { 174 censusAssistantLink.removeAttribute('hidden'); 175 } else { 176 censusAssistantLink.setAttribute('hidden', ''); 177 } 178 179 censusAssistant.setAttribute('hidden', ''); 180 document.querySelector('#ca-census').value = census; 181 document.querySelector('#ca-title').value = censusYear + ' ' + censusPlace + ' - <?= I18N::translate('Census transcript') ?> - <?= strip_tags($individual->getFullName()) ?> - <?= I18N::translate('Household') ?>'; 182 183 fetch('module.php?mod=GEDFact_assistant&mod_action=census-header&census=' + census) 184 .then(function (response) { 185 return response.text(); 186 }) 187 .then(function (text) { 188 document.querySelector('#census-assistant-table thead').innerHTML = text; 189 document.querySelector('#census-assistant-table tbody').innerHTML = ''; 190 }); 191 } 192 193 // When the census assistant is activated, show the input fields 194 function censusAssistantLink () { 195 document.querySelector('#census-selector').setAttribute('hidden', ''); 196 this.setAttribute('hidden', ''); 197 document.getElementById('census-assistant').removeAttribute('hidden'); 198 // Set the current individual as the head of household. 199 censusAssistantHead(); 200 201 return false; 202 } 203 204 // Add the currently selected individual to the census 205 function censusAssistantAdd () { 206 var censusSelector = document.querySelector('#census-selector'); 207 var census = censusSelector.options[censusSelector.selectedIndex].dataset.census; 208 var indi_selector = document.querySelector('#census-assistant-individual'); 209 var xref = indi_selector.options[indi_selector.selectedIndex].value; 210 var headTd = document.querySelector('#census-assistant-table td'); 211 var head = headTd === null ? xref : headTd.innerHTML; 212 213 fetch('module.php?mod=GEDFact_assistant&mod_action=census-individual&census=' + census + '&xref=' + xref + '&head=' + head, {credentials: 'same-origin'}) 214 .then(function (response) { 215 return response.text(); 216 }) 217 .then(function (text) { 218 document.querySelector('#census-assistant-table tbody').innerHTML += text; 219 }); 220 221 return false; 222 } 223 224 // Set the currently selected individual as the head of household 225 function censusAssistantHead () { 226 var censusSelector = document.querySelector('#census-selector'); 227 var census = censusSelector.options[censusSelector.selectedIndex].dataset.census; 228 var indi_selector = document.querySelector('#census-assistant-individual'); 229 var xref = indi_selector.options[indi_selector.selectedIndex].value; 230 231 fetch('module.php?mod=GEDFact_assistant&mod_action=census-individual&census=' + census + '&xref=' + xref + '&head=' + xref, {credentials: 'same-origin'}) 232 .then(function (response) { 233 return response.text(); 234 }) 235 .then(function (text) { 236 document.querySelector('#census-assistant-table tbody').innerHTML = text; 237 }); 238 239 return false; 240 } 241 242 document.querySelector('#census-selector').addEventListener('change', censusAssistantSelect); 243 document.querySelector('#census-assistant-link').addEventListener('click', censusAssistantLink); 244 document.querySelector('#census-assistant-add').addEventListener('click', censusAssistantAdd); 245 document.querySelector('#census-assistant-head').addEventListener('click', censusAssistantHead); 246 </script> 247 <?php 248 } 249 250 /** 251 * @param Individual $individual 252 * @param string $newged 253 * 254 * @return string 255 */ 256 public function updateCensusAssistant(Individual $individual, $fact_id, $newged, $keep_chan) { 257 $ca_title = Filter::post('ca_title'); 258 $ca_place = Filter::post('ca_place'); 259 $ca_citation = Filter::post('ca_citation'); 260 $ca_individuals = Filter::postArray('ca_individuals'); 261 $ca_notes = Filter::post('ca_notes'); 262 $ca_census = Filter::post('ca_census', 'Fisharebest\\\\Webtrees\\\\Census\\\\CensusOf[A-Za-z0-9]+'); 263 264 if ($ca_census !== '' && !empty($ca_individuals)) { 265 $census = new $ca_census; 266 267 $note_text = $this->createNoteText($census, $ca_title, $ca_place, $ca_citation, $ca_individuals, $ca_notes); 268 $note_gedcom = '0 @new@ NOTE ' . str_replace("\n", "\n1 CONT ", $note_text); 269 $note = $individual->getTree()->createRecord($note_gedcom); 270 271 $newged .= "\n2 NOTE @" . $note->getXref() . '@'; 272 273 // Add the census fact to the rest of the household 274 foreach (array_keys($ca_individuals) as $xref) { 275 if ($xref !== $individual->getXref()) { 276 Individual::getInstance($xref, $individual->getTree()) 277 ->updateFact($fact_id, $newged, !$keep_chan); 278 } 279 } 280 } 281 282 return $newged; 283 } 284 285 /** 286 * @param CensusInterface $census 287 * @param string $ca_title 288 * @param string $ca_place 289 * @param string $ca_citation 290 * @param string[][] $ca_individuals 291 * @param string $ca_notes 292 * 293 * @return string 294 */ 295 private function createNoteText(CensusInterface $census, $ca_title, $ca_place, $ca_citation, $ca_individuals, $ca_notes) { 296 $text = $ca_title . "\n" . $ca_citation . "\n" . $ca_place . "\n\n.start_formatted_area.\n\n"; 297 298 foreach ($census->columns() as $n => $column) { 299 if ($n > 0) { 300 $text .= '|'; 301 } 302 $text .= '.b.' . $column->abbreviation(); 303 } 304 305 foreach ($ca_individuals as $xref => $columns) { 306 $text .= "\n" . implode('|', $columns); 307 } 308 309 return $text . "\n.end_formatted_area.\n\n" . $ca_notes; 310 } 311 312 /** 313 * Find a media object. 314 */ 315 private static function mediaFind() { 316 global $WT_TREE; 317 318 $controller = new SimpleController; 319 $filter = Filter::get('filter'); 320 $multiple = Filter::getBool('multiple'); 321 322 $controller 323 ->setPageTitle(I18N::translate('Find an individual')) 324 ->pageHeader(); 325 326 ?> 327 <script> 328 function pasterow (id, name, gend, yob, age, bpl) { 329 window.opener.opener.insertRowToTable(id, name, '', gend, '', yob, age, 'Y', '', bpl); 330 } 331 332 function pasteid (id, name, thumb) { 333 if (thumb) { 334 window.opener.paste_id(id, name, thumb); 335 <?php if (!$multiple) { 336 echo 'window.close();'; 337 } ?> 338 } else { 339 // GEDFact_assistant ======================== 340 if (window.opener.document.getElementById('addlinkQueue')) { 341 window.opener.insertRowToTable(id, name); 342 } 343 window.opener.paste_id(id); 344 if (window.opener.pastename) { 345 window.opener.pastename(name); 346 } 347 <?php if (!$multiple) { 348 echo 'window.close();'; 349 } ?> 350 } 351 } 352 353 function checknames (frm) { 354 if (document.forms[0].subclick) { 355 button = document.forms[0].subclick.value; 356 } else { 357 button = ''; 358 } 359 if (frm.filter.value.length < 2 && button !== 'all') { 360 alert('<?= I18N::translate('Please enter more than one character.') ?>'); 361 frm.filter.focus(); 362 return false; 363 } 364 if (button == 'all') { 365 frm.filter.value = ''; 366 } 367 return true; 368 } 369 </script> 370 371 <?php 372 echo '<div>'; 373 echo '<table class="list_table width90" border="0">'; 374 echo '<tr><td style="padding: 10px;" class="facts_label03 width90">'; // start column for find text header 375 echo $controller->getPageTitle(); 376 echo '</td>'; 377 echo '</tr>'; 378 echo '</table>'; 379 echo '<br>'; 380 echo '<button onclick="window.close();">', I18N::translate('close'), '</button>'; 381 echo '<br>'; 382 383 $filter = trim($filter); 384 $filter_array = explode(' ', preg_replace('/ {2,}/', ' ', $filter)); 385 echo '<table class="tabs_table width90"><tr>'; 386 $myindilist = FunctionsDb::searchIndividualNames($filter_array, [$WT_TREE]); 387 if ($myindilist) { 388 echo '<td class="list_value_wrap"><ul>'; 389 usort($myindilist, '\Fisharebest\Webtrees\GedcomRecord::compare'); 390 foreach ($myindilist as $indi) { 391 $nam = Filter::escapeHtml($indi->getFullName()); 392 echo "<li><a href=\"#\" onclick=\"pasterow( 393 '" . $indi->getXref() . "' , 394 '" . $nam . "' , 395 '" . $indi->getSex() . "' , 396 '" . $indi->getBirthYear() . "' , 397 '" . (1901 - $indi->getBirthYear()) . "' , 398 '" . $indi->getBirthPlace() . "'); return false;\"> 399 <b>" . $indi->getFullName() . '</b> '; 400 401 $born = I18N::translate('Birth'); 402 echo '</span><br><span class="list_item">', $born, ' ', $indi->getBirthYear(), ' ', $indi->getBirthPlace(), '</span></a></li>'; 403 echo '<hr>'; 404 } 405 echo '</ul></td></tr><tr><td class="list_label">', I18N::translate('Total individuals: %s', count($myindilist)), '</tr></td>'; 406 } else { 407 echo '<td class="list_value_wrap">'; 408 echo I18N::translate('No results found.'); 409 echo '</td></tr>'; 410 } 411 echo '</table>'; 412 echo '</div>'; 413 } 414 415 /** 416 * Search for a media object. 417 */ 418 private static function mediaQuery() { 419 global $WT_TREE; 420 421 $iid2 = Filter::get('iid', WT_REGEX_XREF); 422 423 $controller = new SimpleController; 424 $controller 425 ->setPageTitle(I18N::translate('Link to an existing media object')) 426 ->pageHeader(); 427 428 $record = GedcomRecord::getInstance($iid2, $WT_TREE); 429 if ($record) { 430 $headjs = ''; 431 if ($record instanceof Family) { 432 if ($record->getHusband()) { 433 $headjs = $record->getHusband()->getXref(); 434 } elseif ($record->getWife()) { 435 $headjs = $record->getWife()->getXref(); 436 } 437 } 438 ?> 439 <script> 440 function insertId () { 441 if (window.opener.document.getElementById('addlinkQueue')) { 442 // alert('Please move this alert window and examine the contents of the pop-up window, then click OK') 443 window.opener.insertRowToTable('<?= $record->getXref() ?>', '<?= htmlspecialchars($record->getFullName()) ?>', '<?= $headjs ?>'); 444 window.close(); 445 } 446 } 447 </script> 448 <?php 449 } else { 450 ?> 451 <script> 452 function insertId () { 453 window.opener.alert('<?= $iid2 ?> - <?= I18N::translate('Not a valid individual, family, or source ID') ?>'); 454 window.close(); 455 } 456 </script> 457 <?php 458 } 459 ?> 460 <script>window.onLoad = insertId();</script> 461 <?php 462 } 463 464 /** 465 * Convert custom markup into HTML 466 * 467 * @param Note $note 468 * 469 * @return string 470 */ 471 public static function formatCensusNote(Note $note) { 472 if (preg_match('/(.*)((?:\n.*)*)\n\.start_formatted_area\.\n(.+)\n(.+(?:\n.+)*)\n.end_formatted_area\.((?:\n.*)*)/', $note->getNote(), $match)) { 473 // This looks like a census-assistant shared note 474 $title = Filter::escapeHtml($match[1]); 475 $preamble = Filter::escapeHtml($match[2]); 476 $header = Filter::escapeHtml($match[3]); 477 $data = Filter::escapeHtml($match[4]); 478 $postamble = Filter::escapeHtml($match[5]); 479 480 // Get the column headers for the census to which this note refers 481 // requires the fact place & date to match the specific census 482 // censusPlace() (Soundex match) and censusDate() functions 483 $fmt_headers = []; 484 /** @var GedcomRecord[] $linkedRecords */ 485 $linkedRecords = array_merge($note->linkedIndividuals('NOTE'), $note->linkedFamilies('NOTE')); 486 $firstRecord = array_shift($linkedRecords); 487 if ($firstRecord) { 488 $countryCode = ''; 489 $date = ''; 490 foreach ($firstRecord->getFacts('CENS') as $fact) { 491 if (trim($fact->getAttribute('NOTE'), '@') === $note->getXref()) { 492 $date = $fact->getAttribute('DATE'); 493 $place = explode(',', strip_tags($fact->getPlace()->getFullName())); 494 $countryCode = Soundex::daitchMokotoff(array_pop($place)); 495 break; 496 } 497 } 498 499 foreach (Census::allCensusPlaces() as $censusPlace) { 500 if (Soundex::compare($countryCode, Soundex::daitchMokotoff($censusPlace->censusPlace()))) { 501 foreach ($censusPlace->allCensusDates() as $census) { 502 if ($census->censusDate() == $date) { 503 foreach ($census->columns() as $column) { 504 $abbrev = $column->abbreviation(); 505 if ($abbrev) { 506 $description = $column->title() ? $column->title() : I18N::translate('Description unavailable'); 507 $fmt_headers[$abbrev] = '<span title="' . $description . '">' . $abbrev . '</span>'; 508 } 509 } 510 break 2; 511 } 512 } 513 } 514 } 515 } 516 // Substitute header labels and format as HTML 517 $thead = '<tr><th>' . strtr(str_replace('|', '</th><th>', $header), $fmt_headers) . '</th></tr>'; 518 $thead = str_replace('.b.', '', $thead); 519 520 // Format data as HTML 521 $tbody = ''; 522 foreach (explode("\n", $data) as $row) { 523 $tbody .= '<tr>'; 524 foreach (explode('|', $row) as $column) { 525 $tbody .= '<td>' . $column . '</td>'; 526 } 527 $tbody .= '</tr>'; 528 } 529 530 return 531 $title . "\n" . // The newline allows the framework to expand the details and turn the first line into a link 532 '<div class="markdown">' . 533 '<p>' . $preamble . '</p>' . 534 '<table>' . 535 '<thead>' . $thead . '</thead>' . 536 '<tbody>' . $tbody . '</tbody>' . 537 '</table>' . 538 '<p>' . $postamble . '</p>' . 539 '</div>'; 540 } else { 541 // Not a census-assistant shared note - apply default formatting 542 return Filter::formatText($note->getNote(), $note->getTree()); 543 } 544 } 545 546 /** 547 * Generate an HTML row of data for the census header 548 * Add prefix cell (store XREF and drag/drop) 549 * Add suffix cell (delete button) 550 * 551 * @param CensusInterface $census 552 * 553 * @return string 554 */ 555 public static function censusTableHeader(CensusInterface $census) { 556 $html = ''; 557 foreach ($census->columns() as $column) { 558 $html .= '<th class="wt-census-assistant-field" title="' . $column->title() . '">' . $column->abbreviation() . '</th>'; 559 } 560 561 return '<tr class="wt-census-assistant-row"><th hidden></th>' . $html . '<th></th></tr>'; 562 } 563 564 /** 565 * Generate an HTML row of data for the census 566 * Add prefix cell (store XREF and drag/drop) 567 * Add suffix cell (delete button) 568 * 569 * @param CensusInterface $census 570 * 571 * @return string 572 */ 573 public static function censusTableEmptyRow(CensusInterface $census) { 574 return '<tr class="wt-census-assistant-row"><td hidden></td>' . str_repeat('<td class="wt-census-assistant-field"><input type="text" class="form-control wt-census-assistant-form-control"></td>', count($census->columns())) . '<td><a class="icon-remove" href="#" title="' . I18N::translate('Remove') . '"></a></td></tr>'; 575 } 576 577 /** 578 * Generate an HTML row of data for the census 579 * Add prefix cell (store XREF and drag/drop) 580 * Add suffix cell (delete button) 581 * 582 * @param CensusInterface $census 583 * @param Individual $individual 584 * @param Individual $head 585 * 586 * @return string 587 */ 588 public static function censusTableRow(CensusInterface $census, Individual $individual, Individual $head) { 589 $html = ''; 590 foreach ($census->columns() as $column) { 591 $html .= '<td class="wt-census-assistant-field"><input class="form-control wt-census-assistant-form-control" type="text" value="' . $column->generate($individual, $head) . '" name="ca_individuals[' . $individual->getXref() . '][]"></td>'; 592 } 593 594 return '<tr class="wt-census-assistant-row"><td class="wt-census-assistant-field" hidden>' . $individual->getXref() . '</td>' . $html . '<td class="wt-census-assistant-field"><a class="icon-remove" href="#" title="' . I18N::translate('Remove') . '"></a></td></tr>'; 595 } 596} 597