xref: /webtrees/app/Module/CensusAssistantModule.php (revision 2a6fda6001c209d27013f958519efeae6d48e2fb)
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\FunctionsDb;
25use Fisharebest\Webtrees\Functions\FunctionsEdit;
26use Fisharebest\Webtrees\GedcomRecord;
27use Fisharebest\Webtrees\Html;
28use Fisharebest\Webtrees\I18N;
29use Fisharebest\Webtrees\Individual;
30use Fisharebest\Webtrees\Note;
31use Fisharebest\Webtrees\Soundex;
32
33/**
34 * Class CensusAssistantModule
35 */
36class CensusAssistantModule extends AbstractModule {
37	/** {@inheritdoc} */
38	public function getTitle() {
39		return /* I18N: Name of a module */
40			I18N::translate('Census assistant');
41	}
42
43	/** {@inheritdoc} */
44	public function getDescription() {
45		return /* I18N: Description of the “Census assistant” module */
46			I18N::translate('An alternative way to enter census transcripts and link them to individuals.');
47	}
48
49	/**
50	 * This is a general purpose hook, allowing modules to respond to routes
51	 * of the form module.php?mod=FOO&mod_action=BAR
52	 *
53	 * @param string $mod_action
54	 */
55	public function modAction($mod_action) {
56		global $WT_TREE;
57
58		switch ($mod_action) {
59		case 'census-header':
60			header('Content-Type: text/html; charset=utf8');
61			$census = Filter::get('census');
62			echo $this->censusTableHeader(new $census);
63			break;
64
65		case 'census-individual':
66			header('Content-Type: text/html; charset=utf8');
67			$census     = Filter::get('census');
68			$individual = Individual::getInstance(Filter::get('xref'), $WT_TREE);
69			$head       = Individual::getInstance(Filter::get('head'), $WT_TREE);
70			echo $this->censusTableRow(new $census, $individual, $head);
71			break;
72
73		case 'media_find':
74			self::mediaFind();
75			break;
76		case 'media_query_3a':
77			self::mediaQuery();
78			break;
79		default:
80			http_response_code(404);
81		}
82	}
83
84	/**
85	 * @param Individual $individual
86	 */
87	public function createCensusAssistant(Individual $individual) {
88		?>
89
90		<div id="census-assistant-link" hidden>
91			<a href="#">
92				<?= I18N::translate('Create a shared note using the census assistant') ?>
93			</a>
94		</div>
95
96		<div id="census-assistant" hidden>
97			<input type="hidden" name="ca_census" id="ca-census">
98			<div class="form-group">
99				<div class="input-group">
100					<label for="census-assistant-title" class="input-group-addon">
101						<?= I18N::translate('Title') ?>
102					</label>
103					<input class="form-control" id="ca-title" name="ca_title" value="">
104				</div>
105			</div>
106
107			<div class="row">
108				<div class="form-group col-sm-6">
109					<div class="input-group">
110						<label for="census-assistant-citation" class="input-group-addon">
111							<?= I18N::translate('Citation') ?>
112						</label>
113						<input class="form-control" id="census-assistant-citation" name="ca_citation">
114					</div>
115				</div>
116
117				<div class="form-group col-sm-6">
118					<div class="input-group">
119						<label for="census-assistant-place" class="input-group-addon">
120							<?= I18N::translate('Place') ?>
121						</label>
122						<input class="form-control" id="census-assistant-place" name="ca_place">
123					</div>
124				</div>
125			</div>
126
127			<div class="form-group">
128				<div class="input-group">
129					<span class="input-group-addon"><?= I18N::translate('Individuals') ?></span>
130					<?= FunctionsEdit::formControlIndividual($individual, ['id' => 'census-assistant-individual', 'style' => 'width:100%']) ?>
131					<span class="input-group-btn">
132						<button type="button" class="btn btn-primary" id="census-assistant-add">
133							<?= FontAwesome::semanticIcon('add', I18N::translate('Add')) ?>
134						</button>
135					</span>
136					<span class="input-group-btn">
137						<button type="button" class="btn btn-primary" id="census-assistant-head"
138						        title="<?= I18N::translate('Head of household') ?>">
139							<?= FontAwesome::semanticIcon('individual', I18N::translate('Head of household')) ?>
140						</button>
141					</span>
142				</div>
143			</div>
144
145			<table class="table table-bordered table-small table-responsive wt-census-assistant-table"
146			       id="census-assistant-table">
147				<thead class="wt-census-assistant-header"></thead>
148				<tbody class="wt-census-assistant-body"></tbody>
149			</table>
150
151			<div class="form-group">
152				<div class="input-group">
153					<label for="census-assistant-notes" class="input-group-addon">
154						<?= I18N::translate('Notes') ?>
155					</label>
156					<input class="form-control" id="census-assistant-notes" name="ca_notes">
157				</div>
158			</div>
159		</div>
160
161		<script>
162			// When a census date/place is selected, activate the census-assistant
163			function censusAssistantSelect() {
164				var censusAssistantLink = document.querySelector('#census-assistant-link');
165				var censusAssistant     = document.querySelector('#census-assistant');
166				var censusOption        = this.options[this.selectedIndex];
167				var census              = censusOption.dataset.census;
168				var censusPlace         = censusOption.dataset.place;
169				var censusYear          = censusOption.value.substr(-4);
170
171				if (censusOption.value !== '') {
172					censusAssistantLink.removeAttribute('hidden');
173				} else {
174					censusAssistantLink.setAttribute('hidden', '');
175				}
176
177				censusAssistant.setAttribute('hidden', '');
178				document.querySelector('#ca-census').value = census;
179				document.querySelector('#ca-title').value  = censusYear + ' ' + censusPlace + ' - <?= I18N::translate('Census transcript') ?> - <?= strip_tags($individual->getFullName()) ?> - <?= I18N::translate('Household') ?>';
180
181				fetch('module.php?mod=GEDFact_assistant&mod_action=census-header&census=' + census)
182					.then(function (response) {
183						return response.text();
184					})
185					.then(function (text) {
186						document.querySelector('#census-assistant-table thead').innerHTML = text;
187						document.querySelector('#census-assistant-table tbody').innerHTML = '';
188					});
189			}
190
191			// When the census assistant is activated, show the input fields
192			function censusAssistantLink() {
193				document.querySelector('#census-selector').setAttribute('hidden', '');
194				this.setAttribute('hidden', '');
195				document.getElementById('census-assistant').removeAttribute('hidden');
196				// Set the current individual as the head of household.
197				censusAssistantHead();
198
199				return false;
200			}
201
202			// Add the currently selected individual to the census
203			function censusAssistantAdd() {
204				var censusSelector = document.querySelector('#census-selector');
205				var census         = censusSelector.options[censusSelector.selectedIndex].dataset.census;
206				var indi_selector  = document.querySelector('#census-assistant-individual');
207				var xref           = indi_selector.options[indi_selector.selectedIndex].value;
208				var headTd         = document.querySelector('#census-assistant-table td');
209				var head           = headTd === null ? xref : headTd.innerHTML;
210
211				fetch('module.php?mod=GEDFact_assistant&mod_action=census-individual&census=' + census + '&xref=' + xref + '&head=' + head, {credentials: 'same-origin'})
212					.then(function (response) {
213						return response.text();
214					})
215					.then(function (text) {
216						document.querySelector('#census-assistant-table tbody').innerHTML += text;
217					});
218
219				return false;
220			}
221
222			// Set the currently selected individual as the head of household
223			function censusAssistantHead() {
224				var censusSelector = document.querySelector('#census-selector');
225				var census         = censusSelector.options[censusSelector.selectedIndex].dataset.census;
226				var indi_selector  = document.querySelector('#census-assistant-individual');
227				var xref           = indi_selector.options[indi_selector.selectedIndex].value;
228
229				fetch('module.php?mod=GEDFact_assistant&mod_action=census-individual&census=' + census + '&xref=' + xref + '&head=' + xref, {credentials: 'same-origin'})
230					.then(function (response) {
231						return response.text();
232					})
233					.then(function (text) {
234						document.querySelector('#census-assistant-table tbody').innerHTML = text;
235					});
236
237				return false;
238			}
239
240			document.querySelector('#census-selector').addEventListener('change', censusAssistantSelect);
241			document.querySelector('#census-assistant-link').addEventListener('click', censusAssistantLink);
242			document.querySelector('#census-assistant-add').addEventListener('click', censusAssistantAdd);
243			document.querySelector('#census-assistant-head').addEventListener('click', censusAssistantHead);
244		</script>
245		<?php
246	}
247
248	/**
249	 * @param Individual $individual
250	 * @param string     $fact_id
251	 * @param string     $newged
252	 * @param bool       $keep_chan
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				var button = '';
355				if (document.forms[0].subclick) {
356					button = document.forms[0].subclick.value;
357				}
358				if (frm.filter.value.length < 2 && button !== 'all') {
359					alert('<?= I18N::translate('Please enter more than one character.') ?>');
360					frm.filter.focus();
361					return false;
362				}
363				if (button === 'all') {
364					frm.filter.value = '';
365				}
366				return true;
367			}
368		</script>
369
370		<?php
371		echo '<div>';
372		echo '<table class="list_table width90" border="0">';
373		echo '<tr><td style="padding: 10px;" class="facts_label03 width90">'; // start column for find text header
374		echo $controller->getPageTitle();
375		echo '</td>';
376		echo '</tr>';
377		echo '</table>';
378		echo '<br>';
379		echo '<button onclick="window.close();">', I18N::translate('close'), '</button>';
380		echo '<br>';
381
382		$filter       = trim($filter);
383		$filter_array = explode(' ', preg_replace('/ {2,}/', ' ', $filter));
384		echo '<table class="tabs_table width90"><tr>';
385		$myindilist = FunctionsDb::searchIndividualNames($filter_array, [$WT_TREE]);
386		if ($myindilist) {
387			echo '<td class="list_value_wrap"><ul>';
388			usort($myindilist, '\Fisharebest\Webtrees\GedcomRecord::compare');
389			foreach ($myindilist as $indi) {
390				$nam = Html::escape($indi->getFullName());
391				echo "<li><a href=\"#\" onclick=\"pasterow(
392					'" . $indi->getXref() . "' ,
393					'" . $nam . "' ,
394					'" . $indi->getSex() . "' ,
395					'" . $indi->getBirthYear() . "' ,
396					'" . (1901 - $indi->getBirthYear()) . "' ,
397					'" . $indi->getBirthPlace() . "'); return false;\">
398					<b>" . $indi->getFullName() . '</b>&nbsp;&nbsp;&nbsp;';
399
400				$born = I18N::translate('Birth');
401				echo '</span><br><span class="list_item">', $born, ' ', $indi->getBirthYear(), '&nbsp;&nbsp;&nbsp;', $indi->getBirthPlace(), '</span></a></li>';
402				echo '<hr>';
403			}
404			echo '</ul></td></tr><tr><td class="list_label">', I18N::translate('Total individuals: %s', count($myindilist)), '</tr></td>';
405		} else {
406			echo '<td class="list_value_wrap">';
407			echo I18N::translate('No results found.');
408			echo '</td></tr>';
409		}
410		echo '</table>';
411		echo '</div>';
412	}
413
414	/**
415	 * Search for a media object.
416	 */
417	private static function mediaQuery() {
418		global $WT_TREE;
419
420		$iid2 = Filter::get('iid', WT_REGEX_XREF);
421
422		$controller = new SimpleController;
423		$controller
424			->setPageTitle(I18N::translate('Link to an existing media object'))
425			->pageHeader();
426
427		$record = GedcomRecord::getInstance($iid2, $WT_TREE);
428		if ($record) {
429			$headjs = '';
430			if ($record instanceof Family) {
431				if ($record->getHusband()) {
432					$headjs = $record->getHusband()->getXref();
433				} elseif ($record->getWife()) {
434					$headjs = $record->getWife()->getXref();
435				}
436			}
437			?>
438			<script>
439				function insertId() {
440					if (window.opener.document.getElementById('addlinkQueue')) {
441						// alert('Please move this alert window and examine the contents of the pop-up window, then click OK')
442						window.opener.insertRowToTable('<?= $record->getXref() ?>', '<?= htmlspecialchars($record->getFullName()) ?>', '<?= $headjs ?>');
443						window.close();
444					}
445				}
446			</script>
447			<?php
448		} else {
449			?>
450			<script>
451				function insertId() {
452					window.opener.alert('<?= $iid2 ?> - <?= I18N::translate('Not a valid individual, family, or source ID') ?>');
453					window.close();
454				}
455			</script>
456			<?php
457		}
458		?>
459		<script>window.onLoad = insertId();</script>
460		<?php
461	}
462
463	/**
464	 * Convert custom markup into HTML
465	 *
466	 * @param Note $note
467	 *
468	 * @return string
469	 */
470	public static function formatCensusNote(Note $note) {
471		if (preg_match('/(.*)((?:\n.*)*)\n\.start_formatted_area\.\n(.+)\n(.+(?:\n.+)*)\n.end_formatted_area\.((?:\n.*)*)/', $note->getNote(), $match)) {
472			// This looks like a census-assistant shared note
473			$title     = Html::escape($match[1]);
474			$preamble  = Html::escape($match[2]);
475			$header    = Html::escape($match[3]);
476			$data      = Html::escape($match[4]);
477			$postamble = Html::escape($match[5]);
478
479			// Get the column headers for the census to which this note refers
480			// requires the fact place & date to match the specific census
481			// censusPlace() (Soundex match) and censusDate() functions
482			$fmt_headers = [];
483			/** @var GedcomRecord[] $linkedRecords */
484			$linkedRecords = array_merge($note->linkedIndividuals('NOTE'), $note->linkedFamilies('NOTE'));
485			$firstRecord   = array_shift($linkedRecords);
486			if ($firstRecord) {
487				$countryCode = '';
488				$date        = '';
489				foreach ($firstRecord->getFacts('CENS') as $fact) {
490					if (trim($fact->getAttribute('NOTE'), '@') === $note->getXref()) {
491						$date        = $fact->getAttribute('DATE');
492						$place       = explode(',', strip_tags($fact->getPlace()->getFullName()));
493						$countryCode = Soundex::daitchMokotoff(array_pop($place));
494						break;
495					}
496				}
497
498				foreach (Census::allCensusPlaces() as $censusPlace) {
499					if (Soundex::compare($countryCode, Soundex::daitchMokotoff($censusPlace->censusPlace()))) {
500						foreach ($censusPlace->allCensusDates() as $census) {
501							if ($census->censusDate() == $date) {
502								foreach ($census->columns() as $column) {
503									$abbrev = $column->abbreviation();
504									if ($abbrev) {
505										$description          = $column->title() ? $column->title() : I18N::translate('Description unavailable');
506										$fmt_headers[$abbrev] = '<span title="' . $description . '">' . $abbrev . '</span>';
507									}
508								}
509								break 2;
510							}
511						}
512					}
513				}
514			}
515			// Substitute header labels and format as HTML
516			$thead = '<tr><th>' . strtr(str_replace('|', '</th><th>', $header), $fmt_headers) . '</th></tr>';
517			$thead = str_replace('.b.', '', $thead);
518
519			// Format data as HTML
520			$tbody = '';
521			foreach (explode("\n", $data) as $row) {
522				$tbody .= '<tr>';
523				foreach (explode('|', $row) as $column) {
524					$tbody .= '<td>' . $column . '</td>';
525				}
526				$tbody .= '</tr>';
527			}
528
529			return
530				$title . "\n" . // The newline allows the framework to expand the details and turn the first line into a link
531				'<div class="markdown">' .
532				'<p>' . $preamble . '</p>' .
533				'<table>' .
534				'<thead>' . $thead . '</thead>' .
535				'<tbody>' . $tbody . '</tbody>' .
536				'</table>' .
537				'<p>' . $postamble . '</p>' .
538				'</div>';
539		} else {
540			// Not a census-assistant shared note - apply default formatting
541			return Filter::formatText($note->getNote(), $note->getTree());
542		}
543	}
544
545	/**
546	 * Generate an HTML row of data for the census header
547	 * Add prefix cell (store XREF and drag/drop)
548	 * Add suffix cell (delete button)
549	 *
550	 * @param CensusInterface $census
551	 *
552	 * @return string
553	 */
554	public static function censusTableHeader(CensusInterface $census) {
555		$html = '';
556		foreach ($census->columns() as $column) {
557			$html .= '<th class="wt-census-assistant-field" title="' . $column->title() . '">' . $column->abbreviation() . '</th>';
558		}
559
560		return '<tr class="wt-census-assistant-row"><th hidden></th>' . $html . '<th></th></tr>';
561	}
562
563	/**
564	 * Generate an HTML row of data for the census
565	 * Add prefix cell (store XREF and drag/drop)
566	 * Add suffix cell (delete button)
567	 *
568	 * @param CensusInterface $census
569	 *
570	 * @return string
571	 */
572	public static function censusTableEmptyRow(CensusInterface $census) {
573		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>';
574	}
575
576	/**
577	 * Generate an HTML row of data for the census
578	 * Add prefix cell (store XREF and drag/drop)
579	 * Add suffix cell (delete button)
580	 *
581	 * @param CensusInterface $census
582	 * @param Individual      $individual
583	 * @param Individual      $head
584	 *
585	 * @return string
586	 */
587	public static function censusTableRow(CensusInterface $census, Individual $individual, Individual $head) {
588		$html = '';
589		foreach ($census->columns() as $column) {
590			$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>';
591		}
592
593		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>';
594	}
595}
596