. */ namespace Fisharebest\Webtrees; use Fisharebest\Webtrees\Controller\AjaxController; use Fisharebest\Webtrees\Controller\PageController; use Fisharebest\Webtrees\Functions\FunctionsEdit; define('WT_SCRIPT_NAME', 'admin_media.php'); require './includes/session.php'; // type of file/object to include $files = Filter::get('files', 'local|external|unused', 'local'); // family tree setting MEDIA_DIRECTORY $media_folders = all_media_folders(); $media_folder = Filter::get('media_folder', null, ''); // MySQL needs an empty string, not NULL // User folders may contain special characters. Restrict to actual folders. if (!array_key_exists($media_folder, $media_folders)) { $media_folder = reset($media_folders); } // prefix to filename $media_paths = media_paths($media_folder); $media_path = Filter::get('media_path', null, ''); // MySQL needs an empty string, not NULL // User paths may contain special characters. Restrict to actual paths. if (!array_key_exists($media_path, $media_paths)) { $media_path = reset($media_paths); } // subfolders within $media_path $subfolders = Filter::get('subfolders', 'include|exclude', 'include'); $action = Filter::get('action'); //////////////////////////////////////////////////////////////////////////////// // POST callback for file deletion //////////////////////////////////////////////////////////////////////////////// $delete_file = Filter::post('delete'); if ($delete_file) { $controller = new AjaxController; // Only delete valid (i.e. unused) media files $media_folder = Filter::post('media_folder', null, ''); // MySQL needs an empty string, not NULL $disk_files = all_disk_files($media_folder, '', 'include', ''); if (in_array($delete_file, $disk_files)) { $tmp = WT_DATA_DIR . $media_folder . $delete_file; try { unlink($tmp); FlashMessages::addMessage(I18N::translate('The file %s has been deleted.', Html::filename($tmp)), 'success'); } catch (\ErrorException $ex) { FlashMessages::addMessage(I18N::translate('The file %s could not be deleted.', Html::filename($tmp)) . '
' . $ex->getMessage() . '', 'danger'); } // Delete any corresponding thumbnail $tmp = WT_DATA_DIR . $media_folder . 'thumbs/' . $delete_file; if (file_exists($tmp)) { try { unlink($tmp); FlashMessages::addMessage(I18N::translate('The file %s has been deleted.', Html::filename($tmp)), 'success'); } catch (\ErrorException $ex) { FlashMessages::addMessage(I18N::translate('The file %s could not be deleted.', Html::filename($tmp)) . '
' . $ex->getMessage() . '', 'danger'); } } } else { // File no longer exists? Maybe it was already deleted or renamed. } $controller->pageHeader(); return; } //////////////////////////////////////////////////////////////////////////////// // GET callback for server-side pagination //////////////////////////////////////////////////////////////////////////////// switch ($action) { case 'load_json': $search = Filter::get('search'); $search = $search['value']; $start = Filter::getInteger('start'); $length = Filter::getInteger('length'); switch ($files) { case 'local': // Filtered rows $SELECT1 = "SELECT SQL_CACHE SQL_CALC_FOUND_ROWS TRIM(LEADING :media_path_1 FROM m_filename) AS media_path, m_id AS xref, m_titl, m_file AS gedcom_id, m_gedcom AS gedcom" . " FROM `##media`" . " JOIN `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" . " JOIN `##gedcom` USING (gedcom_id)" . " WHERE setting_value = :media_folder" . " AND m_filename LIKE CONCAT(:media_path_2, '%')" . " AND (SUBSTRING_INDEX(m_filename, '/', -1) LIKE CONCAT('%', :search_1, '%')" . " OR m_titl LIKE CONCAT('%', :search_2, '%'))" . " AND m_filename NOT LIKE 'http://%'" . " AND m_filename NOT LIKE 'https://%'"; $ARGS1 = array( 'media_path_1' => $media_path, 'media_folder' => $media_folder, 'media_path_2' => Filter::escapeLike($media_path), 'search_1' => Filter::escapeLike($search), 'search_2' => Filter::escapeLike($search), ); // Unfiltered rows $SELECT2 = "SELECT SQL_CACHE COUNT(*)" . " FROM `##media`" . " JOIN `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" . " WHERE setting_value = :media_folder" . " AND m_filename LIKE CONCAT(:media_path_3, '%')" . " AND m_filename NOT LIKE 'http://%'" . " AND m_filename NOT LIKE 'https://%'"; $ARGS2 = array( 'media_folder' => $media_folder, 'media_path_3' => $media_path, ); if ($subfolders == 'exclude') { $SELECT1 .= " AND m_filename NOT LIKE CONCAT(:media_path_4, '%/%')"; $ARGS1['media_path_4'] = Filter::escapeLike($media_path); $SELECT2 .= " AND m_filename NOT LIKE CONCAT(:media_path_4, '%/%')"; $ARGS2['media_path_4'] = Filter::escapeLike($media_path); } $order = Filter::getArray('order'); $SELECT1 .= " ORDER BY "; if ($order) { foreach ($order as $key => $value) { if ($key > 0) { $SELECT1 .= ','; } // Datatables numbers columns 0, 1, 2 // MySQL numbers columns 1, 2, 3 switch ($value['dir']) { case 'asc': $SELECT1 .= ":col_" . $key . " ASC"; break; case 'desc': $SELECT1 .= ":col_" . $key . " DESC"; break; } $ARGS1['col_' . $key] = 1 + $value['column']; } } else { $SELECT1 = " 1 ASC"; } if ($length > 0) { $SELECT1 .= " LIMIT :length OFFSET :start"; $ARGS1['length'] = $length; $ARGS1['start'] = $start; } $rows = Database::prepare($SELECT1)->execute($ARGS1)->fetchAll(); // Total filtered/unfiltered rows $recordsFiltered = Database::prepare("SELECT FOUND_ROWS()")->fetchOne(); $recordsTotal = Database::prepare($SELECT2)->execute($ARGS2)->fetchOne(); $data = array(); foreach ($rows as $row) { $media = Media::getInstance($row->xref, Tree::findById($row->gedcom_id), $row->gedcom); $data[] = array( mediaFileInfo($media_folder, $media_path, $row->media_path), $media->displayImage(), mediaObjectInfo($media), ); } break; case 'external': // Filtered rows $SELECT1 = "SELECT SQL_CACHE SQL_CALC_FOUND_ROWS m_filename, m_id AS xref, m_titl, m_file AS gedcom_id, m_gedcom AS gedcom" . " FROM `##media`" . " WHERE (m_filename LIKE 'http://%' OR m_filename LIKE 'https://%')" . " AND (m_filename LIKE CONCAT('%', :search_1, '%') OR m_titl LIKE CONCAT('%', :search_2, '%'))"; $ARGS1 = array( 'search_1' => Filter::escapeLike($search), 'search_2' => Filter::escapeLike($search), ); // Unfiltered rows $SELECT2 = "SELECT SQL_CACHE COUNT(*)" . " FROM `##media`" . " WHERE (m_filename LIKE 'http://%' OR m_filename LIKE 'https://%')"; $ARGS2 = array(); $order = Filter::getArray('order'); $SELECT1 .= " ORDER BY "; if ($order) { foreach ($order as $key => $value) { if ($key > 0) { $SELECT1 .= ','; } // Datatables numbers columns 0, 1, 2 // MySQL numbers columns 1, 2, 3 switch ($value['dir']) { case 'asc': $SELECT1 .= ":col_" . $key . " ASC"; break; case 'desc': $SELECT1 .= ":col_" . $key . " DESC"; break; } $ARGS1['col_' . $key] = 1 + $value['column']; } } else { $SELECT1 = " 1 ASC"; } if ($length > 0) { $SELECT1 .= " LIMIT :length OFFSET :start"; $ARGS1['length'] = $length; $ARGS1['start'] = $start; } $rows = Database::prepare($SELECT1)->execute($ARGS1)->fetchAll(); // Total filtered/unfiltered rows $recordsFiltered = Database::prepare("SELECT FOUND_ROWS()")->fetchOne(); $recordsTotal = Database::prepare($SELECT2)->execute($ARGS2)->fetchOne(); $data = array(); foreach ($rows as $row) { $media = Media::getInstance($row->xref, Tree::findById($row->gedcom_id), $row->gedcom); $data[] = array( GedcomTag::getLabelValue('URL', $row->m_filename), $media->displayImage(), mediaObjectInfo($media), ); } break; case 'unused': // Which trees use this media folder? $media_trees = Database::prepare( "SELECT gedcom_name, gedcom_name" . " FROM `##gedcom`" . " JOIN `##gedcom_setting` USING (gedcom_id)" . " WHERE setting_name='MEDIA_DIRECTORY' AND setting_value = :media_folder AND gedcom_id > 0" )->execute(array( 'media_folder' => $media_folder, ))->fetchAssoc(); $disk_files = all_disk_files($media_folder, $media_path, $subfolders, $search); $db_files = all_media_files($media_folder, $media_path, $subfolders, $search); // All unused files $unused_files = array_diff($disk_files, $db_files); $recordsTotal = count($unused_files); // Filter unused files if ($search) { $unused_files = array_filter($unused_files, function ($x) use ($search) { return strpos($x, $search) !== false; }); } $recordsFiltered = count($unused_files); // Sort files - only option is column 0 sort($unused_files); $order = Filter::get('order'); if ($order && $order[0]['dir'] === 'desc') { $unused_files = array_reverse($unused_files); } // Paginate unused files $unused_files = array_slice($unused_files, $start, $length); $data = array(); foreach ($unused_files as $unused_file) { $full_path = WT_DATA_DIR . $media_folder . $media_path . $unused_file; $thumb_path = WT_DATA_DIR . $media_folder . 'thumbs/' . $media_path . $unused_file; if (!file_exists($thumb_path)) { $thumb_path = $full_path; } try { $imgsize = getimagesize($thumb_path); // We can’t create a URL (not in public_html) or use the media firewall (no such object) // so just the base64-encoded image inline. if ($imgsize === false) { // not an image $img = '-'; } else { $img = ''; } } catch (\ErrorException $ex) { // Not an image, or not a valid image? $img = '-'; } // Is there a pending record for this file? $exists_pending = Database::prepare( "SELECT 1 FROM `##change` WHERE status='pending' AND new_gedcom LIKE CONCAT('%\n1 FILE ', :unused_file, '\n%')" )->execute(array( 'unused_file' => Filter::escapeLike($unused_file), ))->fetchOne(); // Form to create new media object in each tree $create_form = ''; if (!$exists_pending) { foreach ($media_trees as $media_tree) { $create_form .= '

' . I18N::translate('Create') . ' — ' . Filter::escapeHtml($media_tree) . '

'; } } $conf = I18N::translate('Are you sure you want to delete “%s”?', Filter::escapeJs($unused_file)); $delete_link = '

' . I18N::translate('Delete') . '

'; $data[] = array( mediaFileInfo($media_folder, $media_path, $unused_file) . $delete_link, $img, $create_form, ); } break; default: throw new \DomainException('Invalid action'); } header('Content-type: application/json'); // See http://www.datatables.net/usage/server-side echo json_encode(array( 'draw' => Filter::getInteger('draw'), // String, but always an integer 'recordsTotal' => $recordsTotal, 'recordsFiltered' => $recordsFiltered, 'data' => $data, )); return; } /** * A unique list of media folders, from all trees. * * @return string[] */ function all_media_folders() { return Database::prepare( "SELECT SQL_CACHE setting_value, setting_value" . " FROM `##gedcom_setting`" . " WHERE setting_name='MEDIA_DIRECTORY' AND gedcom_id > 0" . " GROUP BY 1" . " ORDER BY 1" )->execute(array())->fetchAssoc(); } /** * Generate a list of media paths (within a media folder) used by all media objects. * * @param string $media_folder * * @return string[] */ function media_paths($media_folder) { $media_paths = Database::prepare( "SELECT SQL_CACHE LEFT(m_filename, CHAR_LENGTH(m_filename) - CHAR_LENGTH(SUBSTRING_INDEX(m_filename, '/', -1))) AS media_path" . " FROM `##media`" . " JOIN `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" . " WHERE setting_value = :media_folder" . " AND m_filename NOT LIKE 'http://%'" . " AND m_filename NOT LIKE 'https://%'" . " GROUP BY 1" . " ORDER BY 1" )->execute(array( 'media_folder' => $media_folder, ))->fetchOneColumn(); if (!$media_paths || reset($media_paths) != '') { // Always include a (possibly empty) top-level folder array_unshift($media_paths, ''); } return array_combine($media_paths, $media_paths); } /** * Search a folder (and optional subfolders) for filenames that match a search pattern. * * @param string $dir * @param bool $recursive * @param string $filter * * @return string[] */ function scan_dirs($dir, $recursive, $filter) { $files = array(); // $dir comes from the database. The actual folder may not exist. if (is_dir($dir)) { foreach (scandir($dir) as $path) { if (is_dir($dir . $path)) { // What if there are user-defined subfolders “thumbs” or “watermarks”? if ($path != '.' && $path != '..' && $path != 'thumbs' && $path != 'watermark' && $recursive) { foreach (scan_dirs($dir . $path . '/', $recursive, $filter) as $subpath) { $files[] = $path . '/' . $subpath; } } } elseif (!$filter || stripos($path, $filter) !== false) { $files[] = $path; } } } return $files; } /** * Fetch a list of all files on disk * * @param string $media_folder Location of root folder * @param string $media_path Any subfolder * @param string $subfolders Include or exclude subfolders * @param string $filter Filter files whose name contains this test * * @return string[] */ function all_disk_files($media_folder, $media_path, $subfolders, $filter) { return scan_dirs(WT_DATA_DIR . $media_folder . $media_path, $subfolders == 'include', $filter); } /** * Fetch a list of all files on in the database. * * The subfolders parameter is not implemented. However, as we * currently use this function as an exclusion list, it is harmless * to always include sub-folders. * * @param string $media_folder * @param string $media_path * @param string $subfolders * @param string $filter * * @return string[] */ function all_media_files($media_folder, $media_path, $subfolders, $filter) { return Database::prepare( "SELECT SQL_CACHE SQL_CALC_FOUND_ROWS TRIM(LEADING :media_path_1 FROM m_filename) AS media_path, 'OBJE' AS type, m_titl, m_id AS xref, m_file AS ged_id, m_gedcom AS gedrec, m_filename" . " FROM `##media`" . " JOIN `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" . " JOIN `##gedcom` USING (gedcom_id)" . " WHERE setting_value = :media_folder" . " AND m_filename LIKE CONCAT(:media_path_2, '%')" . " AND (SUBSTRING_INDEX(m_filename, '/', -1) LIKE CONCAT('%', :filter_1, '%')" . " OR m_titl LIKE CONCAT('%', :filter_2, '%'))" . " AND m_filename NOT LIKE 'http://%'" . " AND m_filename NOT LIKE 'https://%'" )->execute(array( 'media_path_1' => $media_path, 'media_folder' => $media_folder, 'media_path_2' => Filter::escapeLike($media_path), 'filter_1' => Filter::escapeLike($filter), 'filter_2' => Filter::escapeLike($filter), ))->fetchOneColumn(); } /** * Generate some useful information and links about a media file. * * @param string $media_folder * @param string $media_path * @param string $file * * @return string */ function mediaFileInfo($media_folder, $media_path, $file) { $html = '
'; $html .= '
' . I18N::translate('Filename') . '
'; $html .= '
' . Filter::escapeHtml($file) . '
'; $full_path = WT_DATA_DIR . $media_folder . $media_path . $file; if ($file && file_exists($full_path)) { try { $size = filesize($full_path); $size = (int) (($size + 1023) / 1024); // Round up to next KB $size = /* I18N: size of file in KB */ I18N::translate('%s KB', I18N::number($size)); $html .= '
' . I18N::translate('File size') . '
'; $html .= '
' . $size . '
'; try { $imgsize = getimagesize($full_path); $html .= '
' . I18N::translate('Image dimensions') . '
'; $html .= '
' . /* I18N: image dimensions, width × height */ I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '
'; } catch (\ErrorException $ex) { // Not an image, or not a valid image? } $html .= '
'; } catch (\ErrorException $ex) { $html .= ''; $html .= '
' . I18N::translate('This media file exists, but cannot be accessed.') . '
'; } } else { $html .= ''; $html .= '
' . I18N::translate('This media file does not exist.') . '
'; } return $html; } /** * Generate some useful information and links about a media object. * * @param Media $media * * @return string HTML */ function mediaObjectInfo(Media $media) { $xref = $media->getXref(); $gedcom = $media->getTree()->getName(); $html = '
' . '' . $media->getFullName() . '' . '
' . Filter::escapeHtml($media->getNote()) . '
'; $html .= '
'; $linked = array(); foreach ($media->linkedIndividuals('OBJE') as $link) { $linked[] = '' . $link->getFullName() . ''; } foreach ($media->linkedFamilies('OBJE') as $link) { $linked[] = '' . $link->getFullName() . ''; } foreach ($media->linkedSources('OBJE') as $link) { $linked[] = '' . $link->getFullName() . ''; } foreach ($media->linkedNotes('OBJE') as $link) { // Invalid GEDCOM - you cannot link a NOTE to an OBJE $linked[] = '' . $link->getFullName() . ''; } foreach ($media->linkedRepositories('OBJE') as $link) { // Invalid GEDCOM - you cannot link a REPO to an OBJE $linked[] = '' . $link->getFullName() . ''; } if ($linked) { $html .= ''; } else { $html .= '
' . I18N::translate('This media object is not linked to any other record.') . '
'; } return $html; } //////////////////////////////////////////////////////////////////////////////// // Start here //////////////////////////////////////////////////////////////////////////////// // Preserve the pagination/filtering/sorting between requests, so that the // browser’s back button works. Pagination is dependent on the currently // selected folder. $table_id = md5($files . $media_folder . $media_path . $subfolders); $controller = new PageController; $controller ->restrictAccess(Auth::isAdmin()) ->setPageTitle(I18N::translate('Manage media')) ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL) ->addExternalJavascript(WT_DATATABLES_BOOTSTRAP_JS_URL) ->pageHeader() ->addInlineJavascript(' jQuery("#media-table-' . $table_id . '").dataTable({ processing: true, serverSide: true, ajax: "' . WT_BASE_URL . WT_SCRIPT_NAME . '?action=load_json&files=' . $files . '&media_folder=' . $media_folder . '&media_path=' . $media_path . '&subfolders=' . $subfolders . '", ' . I18N::datatablesI18N(array(5, 10, 20, 50, 100, 500, 1000, -1)) . ', autoWidth:false, pageLength: 10, pagingType: "full_numbers", stateSave: true, stateDuration: 300, columns: [ {}, { sortable: false }, { sortable: ' . ($files === 'unused' ? 'false' : 'true') . ' } ] }); '); ?>

getPageTitle(); ?>



1): ?>
1): ?>