diff --git a/core/controllers/media.php b/core/controllers/media.php index 07ce688f..3d858190 100644 --- a/core/controllers/media.php +++ b/core/controllers/media.php @@ -76,7 +76,11 @@ public function formats_save() public function media_my_searches() { $this->user->require_authenticated(); - return [true,'Searches',['saved' => $this->models->media('search_get_saved', ['type' => 'saved']), 'history' => $this->models->media('search_get_saved', ['type' => 'history'])]]; + return [true,'Searches',[ + 'saved' => $this->models->media('search_get_saved', ['type' => 'saved']), + 'history' => $this->models->media('search_get_saved', ['type' => 'history']), + 'shared' => $this->models->media('search_get_shared') + ]]; } /** @@ -178,6 +182,55 @@ public function media_my_searches_unset_default() } } + /** + * Share a saved search with users and/or groups. + * + * @param id + * @param user_ids + * @param group_ids + * + * @route POST /v2/media/searches/share + */ + public function media_my_searches_share() + { + $this->user->require_authenticated(); + + $user_ids = $this->data('user_ids'); + $group_ids = $this->data('group_ids'); + + if ($this->models->media('search_share', [ + 'id' => $this->data('id'), + 'user_id' => $this->user->param('id'), + 'user_ids' => is_array($user_ids) ? $user_ids : [], + 'group_ids' => is_array($group_ids) ? $group_ids : [], + ])) { + return [true,'Search shared successfully.']; + } else { + return [false,'Error sharing search.']; + } + } + + /** + * Remove sharing for a saved search. + * + * @param id + * + * @route DELETE /v2/media/searches/share/(:id:) + */ + public function media_my_searches_unshare() + { + $this->user->require_authenticated(); + + if ($this->models->media('search_unshare', [ + 'id' => $this->data('id'), + 'user_id' => $this->user->param('id'), + ])) { + return [true,'Search sharing removed.']; + } else { + return [false,'Error removing search sharing.']; + } + } + /** * Returns a boolean value determining whether the current user can edit the * provided media item. Private method used by a number of other methods in diff --git a/core/models/media_model.php b/core/models/media_model.php index b511d76b..58421663 100644 --- a/core/models/media_model.php +++ b/core/models/media_model.php @@ -658,6 +658,167 @@ public function search_edit($args = []) return true; } + /** + * Share a saved search with users and/or groups. + * + * @param id Search ID to share. + * @param user_id Owner of the search (for validation). + * @param user_ids Array of user IDs to share with. + * @param group_ids Array of group IDs to share with. + * + * @return success + */ + public function search_share($args = []) + { + OBFHelpers::require_args($args, ['id', 'user_id']); + OBFHelpers::default_args($args, ['user_ids' => [], 'group_ids' => []]); + + // verify this search belongs to the user and is saved + $this->db->where('id', $args['id']); + $this->db->where('user_id', $args['user_id']); + $this->db->where('type', 'saved'); + $search = $this->db->get_one('media_searches'); + + if (!$search) { + return false; + } + + // remove existing shares for this search + $this->db->where('search_id', $args['id']); + $this->db->delete('media_searches_shared'); + + // share with users + if (!empty($args['user_ids'])) { + foreach ($args['user_ids'] as $share_user_id) { + $share_user_id = (int) $share_user_id; + + // don't share with self + if ($share_user_id == $args['user_id']) { + continue; + } + $this->db->insert('media_searches_shared', [ + 'search_id' => $args['id'], + 'shared_by' => $args['user_id'], + 'shared_with_user_id' => $share_user_id, + ]); + } + } + + // share with groups + if (!empty($args['group_ids'])) { + foreach ($args['group_ids'] as $group_id) { + $this->db->insert('media_searches_shared', [ + 'search_id' => $args['id'], + 'shared_by' => $args['user_id'], + 'shared_with_group_id' => (int) $group_id, + ]); + } + } + + return true; + } + + /** + * Remove all sharing for a saved search. + * + * @param id Search ID to unshare. + * @param user_id Owner of the search (for validation). + * + * @return success + */ + public function search_unshare($args = []) + { + OBFHelpers::require_args($args, ['id', 'user_id']); + + // verify this search belongs to the user + $this->db->where('id', $args['id']); + $this->db->where('user_id', $args['user_id']); + $search = $this->db->get_one('media_searches'); + + if (!$search) { + return false; + } + + $this->db->where('search_id', $args['id']); + return $this->db->delete('media_searches_shared'); + } + + /** + * Get searches shared with the current user (directly or via groups). + * + * @return searches + */ + public function search_get_shared() + { + if (!$this->user->param('id')) { + return []; + } + + $user_id = $this->db->escape($this->user->param('id')); + + $this->db->query(" + SELECT DISTINCT ms.id, ms.query, ms.description, ms.`default`, + mss.shared_by, + u.display_name AS shared_by_name + FROM media_searches ms + JOIN media_searches_shared mss ON mss.search_id = ms.id + LEFT JOIN users_to_groups utg ON utg.group_id = mss.shared_with_group_id + LEFT JOIN users u ON u.id = mss.shared_by + WHERE mss.shared_with_user_id = \"{$user_id}\" + OR utg.user_id = \"{$user_id}\" + "); + + $searches = $this->db->assoc_list(); + if (!is_array($searches)) { + return []; + } + + foreach ($searches as $index => $search) { + $searches[$index]['query'] = unserialize($search['query']); + } + + return $searches; + } + + /** + * Get the recipients (users and groups) a search is shared with. + * + * @param id Search ID. + * @param user_id Owner of the search (for validation). + * + * @return recipients Array with 'user_ids' and 'group_ids'. + */ + public function search_get_shared_recipients($args = []) + { + OBFHelpers::require_args($args, ['id', 'user_id']); + + // verify ownership + $this->db->where('id', $args['id']); + $this->db->where('user_id', $args['user_id']); + $search = $this->db->get_one('media_searches'); + + if (!$search) { + return false; + } + + $this->db->where('search_id', $args['id']); + $shares = $this->db->get('media_searches_shared'); + + $user_ids = []; + $group_ids = []; + + foreach ($shares as $share) { + if (!empty($share['shared_with_user_id'])) { + $user_ids[] = (int) $share['shared_with_user_id']; + } + if (!empty($share['shared_with_group_id'])) { + $group_ids[] = (int) $share['shared_with_group_id']; + } + } + + return ['user_ids' => $user_ids, 'group_ids' => $group_ids]; + } + /** * Get captions URL for media item * diff --git a/core/updates/20260219.php b/core/updates/20260219.php new file mode 100644 index 00000000..d212958a --- /dev/null +++ b/core/updates/20260219.php @@ -0,0 +1,37 @@ +db->query('CREATE TABLE IF NOT EXISTS `media_searches_shared` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `search_id` INT(10) UNSIGNED NOT NULL, + `shared_by` INT(10) UNSIGNED NOT NULL, + `shared_with_user_id` INT(10) UNSIGNED DEFAULT NULL, + `shared_with_group_id` INT(10) UNSIGNED DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `search_id` (`search_id`), + KEY `shared_by` (`shared_by`), + KEY `shared_with_user_id` (`shared_with_user_id`), + KEY `shared_with_group_id` (`shared_with_group_id`), + FOREIGN KEY (`search_id`) REFERENCES `media_searches`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`shared_by`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`shared_with_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`shared_with_group_id`) REFERENCES `users_groups`(`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;'); + + return true; + } +} diff --git a/public/html/sidebar/my_searches.html b/public/html/sidebar/my_searches.html index 7c959950..2e2e73ae 100644 --- a/public/html/sidebar/my_searches.html +++ b/public/html/sidebar/my_searches.html @@ -11,6 +11,14 @@
+
+ Shared With Me + + + +
+
+
Search History @@ -44,6 +52,11 @@ +
Share
Delete
+ +
+
Search
+
diff --git a/public/html/sidebar/share_search.html b/public/html/sidebar/share_search.html new file mode 100644 index 00000000..293ba58e --- /dev/null +++ b/public/html/sidebar/share_search.html @@ -0,0 +1,40 @@ + + +
+

Select users and/or groups to share this search with.

+ +
+ Share With Users +
+ +
+
+ +
+ Share With Groups +
+ +
+
+ +
+
+ Share + Cancel +
+
+
diff --git a/public/js/sidebar.js b/public/js/sidebar.js index 7565691b..4d4734da 100644 --- a/public/js/sidebar.js +++ b/public/js/sidebar.js @@ -1052,7 +1052,13 @@ OB.Sidebar.playlistSearch = function (more) { }; OB.Sidebar.mySearchesContextMenuOn = function (e, type, id) { - $("#my_searches_item_" + id).addClass("context_menu_on"); + $(".my_searches_item").removeClass("context_menu_on"); + + var itemSelector = "#my_searches_item_" + id; + if (type == "shared") { + itemSelector = "#my_searches_shared_item_" + id; + } + $(itemSelector).addClass("context_menu_on"); $("#my_searches_" + type + "_context_menu") .css("left", e.pageX) @@ -1079,6 +1085,7 @@ OB.Sidebar.mySearchesContextMenuOff = function (e) { $("#my_searches_history_context_menu").hide(); $("#my_searches_saved_context_menu").hide(); + $("#my_searches_shared_context_menu").hide(); $(".my_searches_item").removeClass("context_menu_on"); @@ -1122,10 +1129,18 @@ OB.Sidebar.mySearchesNosearchtext = function () { if ($("#my_searches_saved .my_searches_item").length == 0) $("#my_searches_saved_nosearches").show(); else $("#my_searches_saved_nosearches").hide(); + + if ($("#my_searches_shared .my_searches_item").length == 0) $("#my_searches_shared_nosearches").show(); + else $("#my_searches_shared_nosearches").hide(); }; OB.Sidebar.mySearchesSearch = function (id) { - OB.Sidebar.advanced_search_filters = $("#my_searches_item_" + id).data("filters"); + var $item = $("#my_searches_item_" + id); + if (!$item.length) $item = $("#my_searches_shared_item_" + id); + if (!$item.length) $item = $('.my_searches_item[data-id="' + id + '"]').first(); + if (!$item.length) return; + + OB.Sidebar.advanced_search_filters = $item.data("filters"); OB.Sidebar.mediaSearch(); OB.UI.closeModalWindow(); }; @@ -1136,6 +1151,7 @@ OB.Sidebar.mySearchesWindow = function () { var history = response.data.history; var saved = response.data.saved; + var shared = response.data.shared; if (history && history.length > 0) { $.each(history, function (index, data) { @@ -1149,6 +1165,12 @@ OB.Sidebar.mySearchesWindow = function () { }); } + if (shared && shared.length > 0) { + $.each(shared, function (index, data) { + OB.Sidebar.mySearchesWindowAddItem(data, "shared"); + }); + } + OB.Sidebar.mySearchesNosearchtext(); }); }; @@ -1156,31 +1178,47 @@ OB.Sidebar.mySearchesWindow = function () { OB.Sidebar.mySearchesWindowAddItem = function (data, type) { if (data.query.mode != "advanced") return; // this shouldn't be. + // use unique element id for shared items to avoid collisions with saved items + var itemId = type === "shared" ? "my_searches_shared_item_" + data.id : "my_searches_item_" + data.id; + $("#my_searches_" + type).append( - '
', + '
', ); - $("#my_searches_item_" + data.id).click(function () { + $("#" + itemId).click(function () { OB.Sidebar.mySearchesSearch(data.id); }); - $("#my_searches_item_" + data.id).data("filters", data.query.filters); - $("#my_searches_item_" + data.id).data("description", data.description); + $("#" + itemId).data("filters", data.query.filters); + $("#" + itemId).data("description", data.description); - OB.Sidebar.mySearchesItemContextMenu($("#my_searches_item_" + data.id), type); + OB.Sidebar.mySearchesItemContextMenu($("#" + itemId), type); //T All simple searches will include these filters by default. if (data.default == "1") - $("#my_searches_item_" + data.id).append( + $("#" + itemId).append( '
' + OB.t("All simple searches will include these filters by default.") + "
", ); + // show shared by info for shared items + if (type === "shared" && data.shared_by_name) { + //T Shared by + $("#" + itemId).append( + '
' + + '' + + OB.t("Shared by") + + ": " + + htmlspecialchars(data.shared_by_name) + + "
", + ); + } + if (type == "saved" && data.description != "") { - $("#my_searches_item_" + data.id).append("
" + nl2br(htmlspecialchars(data.description)) + "
"); + $("#" + itemId).append("
" + nl2br(htmlspecialchars(data.description)) + "
"); } else $.each(data.query.filters, function (filter_index, filter) { - $("#my_searches_item_" + data.id).append("
" + htmlspecialchars(filter.description) + "
"); + $("#" + itemId).append("
" + htmlspecialchars(filter.description) + "
"); }); }; @@ -1264,6 +1302,67 @@ OB.Sidebar.mySearchesEdit = function () { }); }; +OB.Sidebar.mySearchesShareWindow = function () { + if (!$(".my_searches_item.context_menu_on").length) return; + + var id = $(".my_searches_item.context_menu_on").attr("data-id"); + OB.Sidebar.mySearchesShareId = id; + + OB.API.post("users", "user_list", {}, function (userResponse) { + OB.API.post("users", "group_list", {}, function (groupResponse) { + OB.UI.openModalWindow("sidebar/share_search.html"); + + var users = userResponse.data || []; + var groups = groupResponse.data || []; + + // populate user select + $.each(users, function (index, user) { + $("#share_search_users").append( + '", + ); + }); + + // populate group select + $.each(groups, function (index, group) { + $("#share_search_groups").append( + '", + ); + }); + }); + }); +}; + +OB.Sidebar.mySearchesShareSubmit = function () { + var userIds = $("#share_search_users").val() || []; + var groupIds = $("#share_search_groups").val() || []; + + if (userIds.length === 0 && groupIds.length === 0) { + //T Please select at least one user or group to share with. + $("#share_search_message").obWidget("error", "Please select at least one user or group to share with."); + return; + } + + OB.API.post( + "media", + "media_my_searches_share", + { + id: OB.Sidebar.mySearchesShareId, + user_ids: userIds, + group_ids: groupIds, + }, + function (response) { + if (response.status == true) { + OB.UI.closeModalWindow(); + //T Search shared successfully. + OB.UI.alert("Search shared successfully."); + } else { + //T An error occurred while trying to share this search. + $("#share_search_message").obWidget("error", "An error occurred while trying to share this search."); + } + }, + ); +}; + OB.Sidebar.advancedSearchWindowInit = function () { // refresh settings and then load window OB.Settings.getSettings(function () {