Files
libremetaverse/LibreMetaverse.RLV/InventoryMap.cs
nooperation d905210ecf Initial commit of LibreMetaverse.RLV and LibreMetaverse.RLV.Tests.
This library provides RLV command processing and ease of use for checking current RLV permissions and restrictions
2025-08-17 19:55:33 -04:00

339 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace LibreMetaverse.RLV
{
public class InventoryMap
{
public ImmutableDictionary<Guid, RlvInventoryItem> Items { get; }
public ImmutableDictionary<Guid, RlvSharedFolder> Folders { get; }
public RlvSharedFolder Root { get; }
/// <summary>
/// Creates a mapping of all items and folders for a given InventoryFolder.
/// </summary>
/// <param name="root">Root of the shared folder. Generally the #RLV folder.</param>
/// <exception cref="ArgumentNullException">root is null</exception>
public InventoryMap(RlvSharedFolder root)
{
if (root == null)
{
throw new ArgumentNullException(nameof(root));
}
var itemsTemp = new Dictionary<Guid, RlvInventoryItem>();
var foldersTemp = new Dictionary<Guid, RlvSharedFolder>();
CreateInventoryMap(root, foldersTemp, itemsTemp);
Root = root;
Items = itemsTemp.ToImmutableDictionary();
Folders = foldersTemp.ToImmutableDictionary();
}
private bool TryGetFolderFromPath_Internal(string path, bool skipPrivateFolders, [NotNullWhen(returnValue: true)] out RlvSharedFolder? folder)
{
if (string.IsNullOrEmpty(path))
{
folder = null;
return false;
}
var iter = Root;
while (true)
{
RlvSharedFolder? candidate = null;
var candidateNameLengthSelected = 0;
var candidatePathRemaining = string.Empty;
var candidateHasPrefix = false;
foreach (var child in iter.Children)
{
if (skipPrivateFolders && child.Name.StartsWith(".", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var fixedChildName = child.Name;
var hasPrefix = false;
// Only fix the child name if we don't already have an exact match with path
if (!path.StartsWith(child.Name, StringComparison.OrdinalIgnoreCase) &&
(
child.Name.StartsWith(".", StringComparison.OrdinalIgnoreCase) ||
child.Name.StartsWith("~", StringComparison.OrdinalIgnoreCase) ||
child.Name.StartsWith("+", StringComparison.OrdinalIgnoreCase)
))
{
fixedChildName = fixedChildName.Substring(1);
hasPrefix = true;
}
if (path.StartsWith(fixedChildName, StringComparison.OrdinalIgnoreCase))
{
// This whole candidate system should probably be redone as a recursive search to find the best possible exact path, but this
// should be good enough for now
//
// We currently pick the best candidate based on:
// 1. The longest candidate that exists at the start of the path and ends with a '/' or matches the remaining path exactly.
// For example, a folder containing "Clothing/Hats" and "Clothing" with a subfolder of "Hats", we would prefer the longest
// match of "Clothing/Hats" first even though in the path they both represent is "#RLV/Clothing/Hats"
//
// 2. Exact matches are preferred over matches that have the prefix removed, for example if we are searching for a "Clothing"
// folder in a folder that contains "Clothing" and "+Clothing", we prefer the one without the prefix first
//
// 3. The first exact match is preferred. If there are multiple "Clothing" folders, just pick the first one that appears
if (candidate == null ||
fixedChildName.Length > candidateNameLengthSelected ||
(fixedChildName.Length == candidateNameLengthSelected && !hasPrefix && candidateHasPrefix))
{
if (path.Length == fixedChildName.Length)
{
candidatePathRemaining = "";
candidate = child;
candidateNameLengthSelected = fixedChildName.Length;
candidateHasPrefix = hasPrefix;
break;
}
if (path.Length > fixedChildName.Length && path[fixedChildName.Length] == '/')
{
candidatePathRemaining = path.Substring(fixedChildName.Length + 1);
candidate = child;
candidateNameLengthSelected = fixedChildName.Length;
candidateHasPrefix = hasPrefix;
}
}
}
}
if (candidate == null)
{
folder = null;
return false;
}
path = candidatePathRemaining;
if (path.Length == 0)
{
folder = candidate;
return true;
}
iter = candidate;
}
}
/// <summary>
/// Attempts to find a folder under the root rlv folder #RLV by the given path.
/// Folders are not case sensitive. Folders may containing a special prefix (~, +),
/// which will be treated as if the folder did not have the prefix, unless the path
/// contains the prefix as well then an exact match will be made.
/// Example:
/// Existing shared folder path: #RLV/Clothing/+Hats/+Fancy
/// search term: "clothing/hats/fancy"
/// results: The object representing Clothing/+Hats/+Fancy
/// </summary>
/// <param name="path">Forward-slash separated folder path. Do not include "#RLV/" as part of the path. Do not start with or end with a forward slash.</param>
/// <param name="skipPrivateFolders">If true, ignores folders starting with '.'</param>
/// <param name="folder">The found folder, or null if not found</param>
/// <returns>True if folder was found, false otherwise</returns>
public bool TryGetFolderFromPath(string path, bool skipPrivateFolders, [NotNullWhen(returnValue: true)] out RlvSharedFolder? folder)
{
if (string.IsNullOrEmpty(path))
{
folder = null;
return false;
}
if (TryGetFolderFromPath_Internal(path, skipPrivateFolders, out folder))
{
return true;
}
// Try without a leading '/' if one was supplied "/~MyOutfit" -> "~MyOutfit"
if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase))
{
var newPath = path.Substring(1);
if (TryGetFolderFromPath_Internal(newPath, skipPrivateFolders, out folder))
{
return true;
}
}
// Try without a trailing '/' if one was supplied "~MyOutfit/" -> "~MyOutfit"
if (path.EndsWith("/", StringComparison.OrdinalIgnoreCase))
{
var newPath = path.Substring(path.Length - 1);
if (TryGetFolderFromPath_Internal(newPath, skipPrivateFolders, out folder))
{
return true;
}
}
// Try without a leading and trailing '/' if they were both supplied "/~MyOutfit/" -> "~MyOutfit"
if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase) && path.EndsWith("/", StringComparison.OrdinalIgnoreCase))
{
var newPath = path.Substring(1, path.Length - 2);
if (TryGetFolderFromPath_Internal(newPath, skipPrivateFolders, out folder))
{
return true;
}
}
return false;
}
/// <summary>
/// Finds all folders containing the specified attachedPrimId, all folders containing an item
/// that is attached to the specified attachment point, or all folders containing an
/// item that is worn as the specified wearable type. Only one search criteria may be
/// specified.
/// </summary>
/// <param name="limitToOneResult">Deprecated, should always be false. Returns only the first found folder. This only exists to support the deprecated @GetPath command</param>
/// <param name="attachedPrimId">If specified, find the folder containing this prim ID</param>
/// <param name="attachmentPoint">If specified, find all folders containing an item currently attached to this attachment point</param>
/// <param name="wearableType">If specified, find all folders containing an item currently worn as this type</param>
/// <returns>Collection of folders matching the search criteria</returns>
public IEnumerable<RlvSharedFolder> FindFoldersContaining(
bool limitToOneResult,
Guid? attachedPrimId,
RlvAttachmentPoint? attachmentPoint,
RlvWearableType? wearableType)
{
var folders = new List<RlvSharedFolder>();
if (attachedPrimId.HasValue)
{
var senderItem = Items
.Where(n => n.Value.AttachedPrimId == attachedPrimId)
.Select(n => n.Value)
.FirstOrDefault();
if (senderItem == null)
{
return [];
}
if (!senderItem.FolderId.HasValue || !Folders.TryGetValue(senderItem.FolderId.Value, out var folder))
{
return [];
}
folders.Add(folder);
}
else if (attachmentPoint.HasValue)
{
var foldersContainingAttachments = new HashSet<Guid>();
foreach (var item in Items.Values)
{
if (item.Folder == null)
{
// External folders are unknown to RLV
continue;
}
if (item.AttachedTo == attachmentPoint)
{
if (foldersContainingAttachments.Add(item.Folder.Id))
{
folders.Add(item.Folder);
if (limitToOneResult)
{
break;
}
}
}
}
}
else if (wearableType.HasValue)
{
var foldersIdsContainingWearables = new HashSet<Guid>();
foreach (var item in Items.Values)
{
if (item.Folder == null)
{
// External folders are unknown to RLV
continue;
}
if (item.WornOn == wearableType)
{
if (foldersIdsContainingWearables.Add(item.Folder.Id))
{
folders.Add(item.Folder);
if (limitToOneResult)
{
break;
}
}
}
}
}
return folders;
}
/// <summary>
/// Attempts to create a path to the specified folder ID
/// Example result:
/// ID of folder (#RLV/Clothing/Hats/Fancy) sets finalPath to "Clothing/Hats/Fancy"
/// </summary>
/// <param name="folderId">ID of the folder to get the path to</param>
/// <param name="finalPath">The path to the folder if function is successful, otherwise null</param>
/// <returns>True if the folder was found and a path was generated, otherwise false</returns>
public bool TryBuildPathToFolder(Guid folderId, [NotNullWhen(true)] out string? finalPath)
{
var path = new Stack<string>();
if (!Folders.TryGetValue(folderId, out var folder))
{
finalPath = null;
return false;
}
var iter = folder;
while (iter != null)
{
// Don't include the root (#RLV) folder itself in the path
if (iter.Parent == null)
{
break;
}
path.Push(iter.Name);
iter = iter.Parent;
}
finalPath = string.Join("/", path);
return true;
}
private static void CreateInventoryMap(
RlvSharedFolder root,
Dictionary<Guid, RlvSharedFolder> folders,
Dictionary<Guid, RlvInventoryItem> items)
{
if (folders.ContainsKey(root.Id))
{
return;
}
folders[root.Id] = root;
foreach (var item in root.Items)
{
items[item.Id] = item;
}
foreach (var child in root.Children)
{
CreateInventoryMap(child, folders, items);
}
}
}
}