1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2019 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees; 21 22use Closure; 23use Fisharebest\Flysystem\Adapter\ChrootAdapter; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Functions\FunctionsExport; 26use Fisharebest\Webtrees\Services\PendingChangesService; 27use Illuminate\Database\Capsule\Manager as DB; 28use Illuminate\Database\Query\Expression; 29use Illuminate\Support\Collection; 30use Illuminate\Support\Str; 31use InvalidArgumentException; 32use League\Flysystem\Filesystem; 33use League\Flysystem\FilesystemInterface; 34use Psr\Http\Message\StreamInterface; 35use stdClass; 36 37use function app; 38 39/** 40 * Provide an interface to the wt_gedcom table. 41 */ 42class Tree 43{ 44 private const RESN_PRIVACY = [ 45 'none' => Auth::PRIV_PRIVATE, 46 'privacy' => Auth::PRIV_USER, 47 'confidential' => Auth::PRIV_NONE, 48 'hidden' => Auth::PRIV_HIDE, 49 ]; 50 51 /** @var int The tree's ID number */ 52 private $id; 53 54 /** @var string The tree's name */ 55 private $name; 56 57 /** @var string The tree's title */ 58 private $title; 59 60 /** @var int[] Default access rules for facts in this tree */ 61 private $fact_privacy; 62 63 /** @var int[] Default access rules for individuals in this tree */ 64 private $individual_privacy; 65 66 /** @var integer[][] Default access rules for individual facts in this tree */ 67 private $individual_fact_privacy; 68 69 /** @var string[] Cached copy of the wt_gedcom_setting table. */ 70 private $preferences = []; 71 72 /** @var string[][] Cached copy of the wt_user_gedcom_setting table. */ 73 private $user_preferences = []; 74 75 /** 76 * Create a tree object. 77 * 78 * @param int $id 79 * @param string $name 80 * @param string $title 81 */ 82 public function __construct(int $id, string $name, string $title) 83 { 84 $this->id = $id; 85 $this->name = $name; 86 $this->title = $title; 87 $this->fact_privacy = []; 88 $this->individual_privacy = []; 89 $this->individual_fact_privacy = []; 90 91 // Load the privacy settings for this tree 92 $rows = DB::table('default_resn') 93 ->where('gedcom_id', '=', $this->id) 94 ->get(); 95 96 foreach ($rows as $row) { 97 // Convert GEDCOM privacy restriction to a webtrees access level. 98 $row->resn = self::RESN_PRIVACY[$row->resn]; 99 100 if ($row->xref !== null) { 101 if ($row->tag_type !== null) { 102 $this->individual_fact_privacy[$row->xref][$row->tag_type] = $row->resn; 103 } else { 104 $this->individual_privacy[$row->xref] = $row->resn; 105 } 106 } else { 107 $this->fact_privacy[$row->tag_type] = $row->resn; 108 } 109 } 110 } 111 112 /** 113 * A closure which will create a record from a database row. 114 * 115 * @return Closure 116 */ 117 public static function rowMapper(): Closure 118 { 119 return static function (stdClass $row): Tree { 120 return new Tree((int) $row->tree_id, $row->tree_name, $row->tree_title); 121 }; 122 } 123 124 /** 125 * Set the tree’s configuration settings. 126 * 127 * @param string $setting_name 128 * @param string $setting_value 129 * 130 * @return $this 131 */ 132 public function setPreference(string $setting_name, string $setting_value): Tree 133 { 134 if ($setting_value !== $this->getPreference($setting_name)) { 135 DB::table('gedcom_setting')->updateOrInsert([ 136 'gedcom_id' => $this->id, 137 'setting_name' => $setting_name, 138 ], [ 139 'setting_value' => $setting_value, 140 ]); 141 142 $this->preferences[$setting_name] = $setting_value; 143 144 Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '"', $this); 145 } 146 147 return $this; 148 } 149 150 /** 151 * Get the tree’s configuration settings. 152 * 153 * @param string $setting_name 154 * @param string $default 155 * 156 * @return string 157 */ 158 public function getPreference(string $setting_name, string $default = ''): string 159 { 160 if ($this->preferences === []) { 161 $this->preferences = DB::table('gedcom_setting') 162 ->where('gedcom_id', '=', $this->id) 163 ->pluck('setting_value', 'setting_name') 164 ->all(); 165 } 166 167 return $this->preferences[$setting_name] ?? $default; 168 } 169 170 /** 171 * The name of this tree 172 * 173 * @return string 174 */ 175 public function name(): string 176 { 177 return $this->name; 178 } 179 180 /** 181 * The title of this tree 182 * 183 * @return string 184 */ 185 public function title(): string 186 { 187 return $this->title; 188 } 189 190 /** 191 * The fact-level privacy for this tree. 192 * 193 * @return int[] 194 */ 195 public function getFactPrivacy(): array 196 { 197 return $this->fact_privacy; 198 } 199 200 /** 201 * The individual-level privacy for this tree. 202 * 203 * @return int[] 204 */ 205 public function getIndividualPrivacy(): array 206 { 207 return $this->individual_privacy; 208 } 209 210 /** 211 * The individual-fact-level privacy for this tree. 212 * 213 * @return int[][] 214 */ 215 public function getIndividualFactPrivacy(): array 216 { 217 return $this->individual_fact_privacy; 218 } 219 220 /** 221 * Set the tree’s user-configuration settings. 222 * 223 * @param UserInterface $user 224 * @param string $setting_name 225 * @param string $setting_value 226 * 227 * @return $this 228 */ 229 public function setUserPreference(UserInterface $user, string $setting_name, string $setting_value): Tree 230 { 231 if ($this->getUserPreference($user, $setting_name) !== $setting_value) { 232 // Update the database 233 DB::table('user_gedcom_setting')->updateOrInsert([ 234 'gedcom_id' => $this->id(), 235 'user_id' => $user->id(), 236 'setting_name' => $setting_name, 237 ], [ 238 'setting_value' => $setting_value, 239 ]); 240 241 // Update the cache 242 $this->user_preferences[$user->id()][$setting_name] = $setting_value; 243 // Audit log of changes 244 Log::addConfigurationLog('Tree preference "' . $setting_name . '" set to "' . $setting_value . '" for user "' . $user->userName() . '"', $this); 245 } 246 247 return $this; 248 } 249 250 /** 251 * Get the tree’s user-configuration settings. 252 * 253 * @param UserInterface $user 254 * @param string $setting_name 255 * @param string $default 256 * 257 * @return string 258 */ 259 public function getUserPreference(UserInterface $user, string $setting_name, string $default = ''): string 260 { 261 // There are lots of settings, and we need to fetch lots of them on every page 262 // so it is quicker to fetch them all in one go. 263 if (!array_key_exists($user->id(), $this->user_preferences)) { 264 $this->user_preferences[$user->id()] = DB::table('user_gedcom_setting') 265 ->where('user_id', '=', $user->id()) 266 ->where('gedcom_id', '=', $this->id) 267 ->pluck('setting_value', 'setting_name') 268 ->all(); 269 } 270 271 return $this->user_preferences[$user->id()][$setting_name] ?? $default; 272 } 273 274 /** 275 * The ID of this tree 276 * 277 * @return int 278 */ 279 public function id(): int 280 { 281 return $this->id; 282 } 283 284 /** 285 * Can a user accept changes for this tree? 286 * 287 * @param UserInterface $user 288 * 289 * @return bool 290 */ 291 public function canAcceptChanges(UserInterface $user): bool 292 { 293 return Auth::isModerator($this, $user); 294 } 295 296 /** 297 * Are there any pending edits for this tree, than need reviewing by a moderator. 298 * 299 * @return bool 300 */ 301 public function hasPendingEdit(): bool 302 { 303 return DB::table('change') 304 ->where('gedcom_id', '=', $this->id) 305 ->where('status', '=', 'pending') 306 ->exists(); 307 } 308 309 /** 310 * Delete everything relating to a tree 311 * 312 * @return void 313 */ 314 public function delete(): void 315 { 316 // If this is the default tree, then unset it 317 if (Site::getPreference('DEFAULT_GEDCOM') === $this->name) { 318 Site::setPreference('DEFAULT_GEDCOM', ''); 319 } 320 321 $this->deleteGenealogyData(false); 322 323 DB::table('block_setting') 324 ->join('block', 'block.block_id', '=', 'block_setting.block_id') 325 ->where('gedcom_id', '=', $this->id) 326 ->delete(); 327 DB::table('block')->where('gedcom_id', '=', $this->id)->delete(); 328 DB::table('user_gedcom_setting')->where('gedcom_id', '=', $this->id)->delete(); 329 DB::table('gedcom_setting')->where('gedcom_id', '=', $this->id)->delete(); 330 DB::table('module_privacy')->where('gedcom_id', '=', $this->id)->delete(); 331 DB::table('hit_counter')->where('gedcom_id', '=', $this->id)->delete(); 332 DB::table('default_resn')->where('gedcom_id', '=', $this->id)->delete(); 333 DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete(); 334 DB::table('log')->where('gedcom_id', '=', $this->id)->delete(); 335 DB::table('gedcom')->where('gedcom_id', '=', $this->id)->delete(); 336 } 337 338 /** 339 * Delete all the genealogy data from a tree - in preparation for importing 340 * new data. Optionally retain the media data, for when the user has been 341 * editing their data offline using an application which deletes (or does not 342 * support) media data. 343 * 344 * @param bool $keep_media 345 * 346 * @return void 347 */ 348 public function deleteGenealogyData(bool $keep_media): void 349 { 350 DB::table('gedcom_chunk')->where('gedcom_id', '=', $this->id)->delete(); 351 DB::table('individuals')->where('i_file', '=', $this->id)->delete(); 352 DB::table('families')->where('f_file', '=', $this->id)->delete(); 353 DB::table('sources')->where('s_file', '=', $this->id)->delete(); 354 DB::table('other')->where('o_file', '=', $this->id)->delete(); 355 DB::table('places')->where('p_file', '=', $this->id)->delete(); 356 DB::table('placelinks')->where('pl_file', '=', $this->id)->delete(); 357 DB::table('name')->where('n_file', '=', $this->id)->delete(); 358 DB::table('dates')->where('d_file', '=', $this->id)->delete(); 359 DB::table('change')->where('gedcom_id', '=', $this->id)->delete(); 360 361 if ($keep_media) { 362 DB::table('link')->where('l_file', '=', $this->id) 363 ->where('l_type', '<>', 'OBJE') 364 ->delete(); 365 } else { 366 DB::table('link')->where('l_file', '=', $this->id)->delete(); 367 DB::table('media_file')->where('m_file', '=', $this->id)->delete(); 368 DB::table('media')->where('m_file', '=', $this->id)->delete(); 369 } 370 } 371 372 /** 373 * Export the tree to a GEDCOM file 374 * 375 * @param resource $stream 376 * 377 * @return void 378 */ 379 public function exportGedcom($stream): void 380 { 381 $buffer = FunctionsExport::reformatRecord(FunctionsExport::gedcomHeader($this, 'UTF-8')); 382 383 $union_families = DB::table('families') 384 ->where('f_file', '=', $this->id) 385 ->select(['f_gedcom AS gedcom', 'f_id AS xref', new Expression('LENGTH(f_id) AS len'), new Expression('2 AS n')]); 386 387 $union_sources = DB::table('sources') 388 ->where('s_file', '=', $this->id) 389 ->select(['s_gedcom AS gedcom', 's_id AS xref', new Expression('LENGTH(s_id) AS len'), new Expression('3 AS n')]); 390 391 $union_other = DB::table('other') 392 ->where('o_file', '=', $this->id) 393 ->whereNotIn('o_type', [Header::RECORD_TYPE, 'TRLR']) 394 ->select(['o_gedcom AS gedcom', 'o_id AS xref', new Expression('LENGTH(o_id) AS len'), new Expression('4 AS n')]); 395 396 $union_media = DB::table('media') 397 ->where('m_file', '=', $this->id) 398 ->select(['m_gedcom AS gedcom', 'm_id AS xref', new Expression('LENGTH(m_id) AS len'), new Expression('5 AS n')]); 399 400 DB::table('individuals') 401 ->where('i_file', '=', $this->id) 402 ->select(['i_gedcom AS gedcom', 'i_id AS xref', new Expression('LENGTH(i_id) AS len'), new Expression('1 AS n')]) 403 ->union($union_families) 404 ->union($union_sources) 405 ->union($union_other) 406 ->union($union_media) 407 ->orderBy('n') 408 ->orderBy('len') 409 ->orderBy('xref') 410 ->chunk(1000, static function (Collection $rows) use ($stream, &$buffer): void { 411 foreach ($rows as $row) { 412 $buffer .= FunctionsExport::reformatRecord($row->gedcom); 413 if (strlen($buffer) > 65535) { 414 fwrite($stream, $buffer); 415 $buffer = ''; 416 } 417 } 418 }); 419 420 fwrite($stream, $buffer . '0 TRLR' . Gedcom::EOL); 421 } 422 423 /** 424 * Import data from a gedcom file into this tree. 425 * 426 * @param StreamInterface $stream The GEDCOM file. 427 * @param string $filename The preferred filename, for export/download. 428 * 429 * @return void 430 */ 431 public function importGedcomFile(StreamInterface $stream, string $filename): void 432 { 433 // Read the file in blocks of roughly 64K. Ensure that each block 434 // contains complete gedcom records. This will ensure we don’t split 435 // multi-byte characters, as well as simplifying the code to import 436 // each block. 437 438 $file_data = ''; 439 440 $this->deleteGenealogyData((bool) $this->getPreference('keep_media')); 441 $this->setPreference('gedcom_filename', $filename); 442 $this->setPreference('imported', '0'); 443 444 while (!$stream->eof()) { 445 $file_data .= $stream->read(65536); 446 // There is no strrpos() function that searches for substrings :-( 447 for ($pos = strlen($file_data) - 1; $pos > 0; --$pos) { 448 if ($file_data[$pos] === '0' && ($file_data[$pos - 1] === "\n" || $file_data[$pos - 1] === "\r")) { 449 // We’ve found the last record boundary in this chunk of data 450 break; 451 } 452 } 453 if ($pos) { 454 DB::table('gedcom_chunk')->insert([ 455 'gedcom_id' => $this->id, 456 'chunk_data' => substr($file_data, 0, $pos), 457 ]); 458 459 $file_data = substr($file_data, $pos); 460 } 461 } 462 DB::table('gedcom_chunk')->insert([ 463 'gedcom_id' => $this->id, 464 'chunk_data' => $file_data, 465 ]); 466 467 $stream->close(); 468 } 469 470 /** 471 * Create a new record from GEDCOM data. 472 * 473 * @param string $gedcom 474 * 475 * @return GedcomRecord|Individual|Family|Note|Source|Repository|Media|Submitter|Submission 476 * @throws InvalidArgumentException 477 */ 478 public function createRecord(string $gedcom): GedcomRecord 479 { 480 if (!Str::startsWith($gedcom, '0 @@ ')) { 481 throw new InvalidArgumentException('GedcomRecord::createRecord(' . $gedcom . ') does not begin 0 @@'); 482 } 483 484 $xref = $this->getNewXref(); 485 $gedcom = '0 @' . $xref . '@ ' . Str::after($gedcom, '0 @@ '); 486 487 // Create a change record 488 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 489 490 // Create a pending change 491 DB::table('change')->insert([ 492 'gedcom_id' => $this->id, 493 'xref' => $xref, 494 'old_gedcom' => '', 495 'new_gedcom' => $gedcom, 496 'user_id' => Auth::id(), 497 ]); 498 499 // Accept this pending change 500 if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS)) { 501 $record = new GedcomRecord($xref, $gedcom, null, $this); 502 503 app(PendingChangesService::class)->acceptRecord($record); 504 505 return $record; 506 } 507 508 return new GedcomRecord($xref, '', $gedcom, $this); 509 } 510 511 /** 512 * Generate a new XREF, unique across all family trees 513 * 514 * @return string 515 */ 516 public function getNewXref(): string 517 { 518 // Lock the row, so that only one new XREF may be generated at a time. 519 DB::table('site_setting') 520 ->where('setting_name', '=', 'next_xref') 521 ->lockForUpdate() 522 ->get(); 523 524 $prefix = 'X'; 525 526 $increment = 1.0; 527 do { 528 $num = (int) Site::getPreference('next_xref') + (int) $increment; 529 530 // This exponential increment allows us to scan over large blocks of 531 // existing data in a reasonable time. 532 $increment *= 1.01; 533 534 $xref = $prefix . $num; 535 536 // Records may already exist with this sequence number. 537 $already_used = 538 DB::table('individuals')->where('i_id', '=', $xref)->exists() || 539 DB::table('families')->where('f_id', '=', $xref)->exists() || 540 DB::table('sources')->where('s_id', '=', $xref)->exists() || 541 DB::table('media')->where('m_id', '=', $xref)->exists() || 542 DB::table('other')->where('o_id', '=', $xref)->exists() || 543 DB::table('change')->where('xref', '=', $xref)->exists(); 544 } while ($already_used); 545 546 Site::setPreference('next_xref', (string) $num); 547 548 return $xref; 549 } 550 551 /** 552 * Create a new family from GEDCOM data. 553 * 554 * @param string $gedcom 555 * 556 * @return Family 557 * @throws InvalidArgumentException 558 */ 559 public function createFamily(string $gedcom): GedcomRecord 560 { 561 if (!Str::startsWith($gedcom, '0 @@ FAM')) { 562 throw new InvalidArgumentException('GedcomRecord::createFamily(' . $gedcom . ') does not begin 0 @@ FAM'); 563 } 564 565 $xref = $this->getNewXref(); 566 $gedcom = '0 @' . $xref . '@ FAM' . Str::after($gedcom, '0 @@ FAM'); 567 568 // Create a change record 569 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 570 571 // Create a pending change 572 DB::table('change')->insert([ 573 'gedcom_id' => $this->id, 574 'xref' => $xref, 575 'old_gedcom' => '', 576 'new_gedcom' => $gedcom, 577 'user_id' => Auth::id(), 578 ]); 579 580 // Accept this pending change 581 if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') { 582 $record = new Family($xref, $gedcom, null, $this); 583 584 app(PendingChangesService::class)->acceptRecord($record); 585 586 return $record; 587 } 588 589 return new Family($xref, '', $gedcom, $this); 590 } 591 592 /** 593 * Create a new individual from GEDCOM data. 594 * 595 * @param string $gedcom 596 * 597 * @return Individual 598 * @throws InvalidArgumentException 599 */ 600 public function createIndividual(string $gedcom): GedcomRecord 601 { 602 if (!Str::startsWith($gedcom, '0 @@ INDI')) { 603 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ INDI'); 604 } 605 606 $xref = $this->getNewXref(); 607 $gedcom = '0 @' . $xref . '@ INDI' . Str::after($gedcom, '0 @@ INDI'); 608 609 // Create a change record 610 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 611 612 // Create a pending change 613 DB::table('change')->insert([ 614 'gedcom_id' => $this->id, 615 'xref' => $xref, 616 'old_gedcom' => '', 617 'new_gedcom' => $gedcom, 618 'user_id' => Auth::id(), 619 ]); 620 621 // Accept this pending change 622 if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') { 623 $record = new Individual($xref, $gedcom, null, $this); 624 625 app(PendingChangesService::class)->acceptRecord($record); 626 627 return $record; 628 } 629 630 return new Individual($xref, '', $gedcom, $this); 631 } 632 633 /** 634 * Create a new media object from GEDCOM data. 635 * 636 * @param string $gedcom 637 * 638 * @return Media 639 * @throws InvalidArgumentException 640 */ 641 public function createMediaObject(string $gedcom): Media 642 { 643 if (!Str::startsWith($gedcom, '0 @@ OBJE')) { 644 throw new InvalidArgumentException('GedcomRecord::createIndividual(' . $gedcom . ') does not begin 0 @@ OBJE'); 645 } 646 647 $xref = $this->getNewXref(); 648 $gedcom = '0 @' . $xref . '@ OBJE' . Str::after($gedcom, '0 @@ OBJE'); 649 650 // Create a change record 651 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName(); 652 653 // Create a pending change 654 DB::table('change')->insert([ 655 'gedcom_id' => $this->id, 656 'xref' => $xref, 657 'old_gedcom' => '', 658 'new_gedcom' => $gedcom, 659 'user_id' => Auth::id(), 660 ]); 661 662 // Accept this pending change 663 if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') { 664 $record = new Media($xref, $gedcom, null, $this); 665 666 app(PendingChangesService::class)->acceptRecord($record); 667 668 return $record; 669 } 670 671 return new Media($xref, '', $gedcom, $this); 672 } 673 674 /** 675 * What is the most significant individual in this tree. 676 * 677 * @param UserInterface $user 678 * @param string $xref 679 * 680 * @return Individual 681 */ 682 public function significantIndividual(UserInterface $user, $xref = ''): Individual 683 { 684 if ($xref === '') { 685 $individual = null; 686 } else { 687 $individual = Individual::getInstance($xref, $this); 688 689 if ($individual === null) { 690 $family = Family::getInstance($xref, $this); 691 692 if ($family instanceof Family) { 693 $individual = $family->spouses()->first() ?? $family->children()->first(); 694 } 695 } 696 } 697 698 if ($individual === null && $this->getUserPreference($user, User::PREF_TREE_DEFAULT_XREF) !== '') { 699 $individual = Individual::getInstance($this->getUserPreference($user, User::PREF_TREE_DEFAULT_XREF), $this); 700 } 701 702 if ($individual === null && $this->getUserPreference($user, User::PREF_TREE_ACCOUNT_XREF) !== '') { 703 $individual = Individual::getInstance($this->getUserPreference($user, User::PREF_TREE_ACCOUNT_XREF), $this); 704 } 705 706 if ($individual === null && $this->getPreference('PEDIGREE_ROOT_ID') !== '') { 707 $individual = Individual::getInstance($this->getPreference('PEDIGREE_ROOT_ID'), $this); 708 } 709 if ($individual === null) { 710 $xref = (string) DB::table('individuals') 711 ->where('i_file', '=', $this->id()) 712 ->min('i_id'); 713 714 $individual = Individual::getInstance($xref, $this); 715 } 716 if ($individual === null) { 717 // always return a record 718 $individual = new Individual('I', '0 @I@ INDI', null, $this); 719 } 720 721 return $individual; 722 } 723 724 /** 725 * Where do we store our media files. 726 * 727 * @param FilesystemInterface $data_filesystem 728 * 729 * @return FilesystemInterface 730 */ 731 public function mediaFilesystem(FilesystemInterface $data_filesystem): FilesystemInterface 732 { 733 $media_dir = $this->getPreference('MEDIA_DIRECTORY', 'media/'); 734 $adapter = new ChrootAdapter($data_filesystem, $media_dir); 735 736 return new Filesystem($adapter); 737 } 738} 739