Add node-level reactive document shortcuts
This commit is contained in:
parent
8a88ae547d
commit
8cc147d367
2 changed files with 377 additions and 62 deletions
|
|
@ -4,6 +4,7 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
|
@ -133,35 +134,35 @@ namespace GameCult.Mesh
|
|||
/// <summary>Gets or sets the message id prefix used for snapshot requests.</summary>
|
||||
public string? MessageIdPrefix { get; set; }
|
||||
|
||||
/// <summary>Gets or sets schema ids to request. Empty means the document type schema is requested.</summary>
|
||||
public IReadOnlyList<string>? SchemaIds { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the target shard id, when the endpoint is shard-aware.</summary>
|
||||
public string? ShardId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the target shard epoch, when the endpoint is shard-aware.</summary>
|
||||
public long? ShardEpoch { get; set; }
|
||||
|
||||
/// <summary>Gets or sets schema ids to request. Empty means the document type schema is requested.</summary>
|
||||
public IReadOnlyList<string>? SchemaIds { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the target shard id, when the endpoint is shard-aware.</summary>
|
||||
public string? ShardId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the target shard epoch, when the endpoint is shard-aware.</summary>
|
||||
public long? ShardEpoch { get; set; }
|
||||
|
||||
/// <summary>Gets or sets client security options for endpoint-created clients.</summary>
|
||||
public ClientSecurityOptions? Security { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a callback used to configure endpoint-created LiteNetLib clients.</summary>
|
||||
public Action<Client>? ConfigureClient { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the runtime id used by endpoint-created RUDP clients.</summary>
|
||||
public string? RudpRuntimeId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the connection id used by endpoint-created RUDP clients.</summary>
|
||||
public uint RudpConnectionId { get; set; } = 0x43554c54;
|
||||
|
||||
/// <summary>Gets or sets the connect payload used by endpoint-created RUDP clients.</summary>
|
||||
public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp";
|
||||
|
||||
/// <summary>Gets or sets the maximum fragment size used by endpoint-created RUDP clients.</summary>
|
||||
public int RudpMaxFragmentBytes { get; set; } = 1024;
|
||||
|
||||
/// <summary>Gets or sets the resend delay used by endpoint-created RUDP clients.</summary>
|
||||
public long RudpResendDelayMs { get; set; } = 25;
|
||||
|
||||
/// <summary>Gets or sets the runtime id used by endpoint-created RUDP clients.</summary>
|
||||
public string? RudpRuntimeId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the connection id used by endpoint-created RUDP clients.</summary>
|
||||
public uint RudpConnectionId { get; set; } = 0x43554c54;
|
||||
|
||||
/// <summary>Gets or sets the connect payload used by endpoint-created RUDP clients.</summary>
|
||||
public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp";
|
||||
|
||||
/// <summary>Gets or sets the maximum fragment size used by endpoint-created RUDP clients.</summary>
|
||||
public int RudpMaxFragmentBytes { get; set; } = 1024;
|
||||
|
||||
/// <summary>Gets or sets the resend delay used by endpoint-created RUDP clients.</summary>
|
||||
public long RudpResendDelayMs { get; set; } = 25;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -337,6 +338,114 @@ namespace GameCult.Mesh
|
|||
return _host.FlushAsync(soft);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
|
||||
CultRecordKey key,
|
||||
CultMeshVerseContext context,
|
||||
string? documentId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
return CultMesh.Document<TDocument>(Database, key, context, documentId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
|
||||
CultRecordKey key,
|
||||
CultMeshVerse verse,
|
||||
string? documentId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return Document<TDocument>(key, verse.Context, documentId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
|
||||
string recordKey,
|
||||
CultMeshVerseContext context,
|
||||
string? documentId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
return Document<TDocument>(new CultRecordKey(recordKey), context, documentId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
|
||||
string recordKey,
|
||||
CultMeshVerse verse,
|
||||
string? documentId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return Document<TDocument>(recordKey, verse.Context, documentId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed reactive document mirror over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public Task<CultMeshReactiveDocument<TDocument>> ReactiveDocumentAsync<TDocument>(
|
||||
CultRecordKey key,
|
||||
CultMeshVerseContext context,
|
||||
CultMeshReactiveDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
return Document<TDocument>(key, context).ReactiveAsync(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed reactive document mirror over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public Task<CultMeshReactiveDocument<TDocument>> ReactiveDocumentAsync<TDocument>(
|
||||
CultRecordKey key,
|
||||
CultMeshVerse verse,
|
||||
CultMeshReactiveDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return ReactiveDocumentAsync<TDocument>(key, verse.Context, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed reactive document mirror over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public Task<CultMeshReactiveDocument<TDocument>> ReactiveDocumentAsync<TDocument>(
|
||||
string recordKey,
|
||||
CultMeshVerseContext context,
|
||||
CultMeshReactiveDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
return ReactiveDocumentAsync<TDocument>(new CultRecordKey(recordKey), context, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed reactive document mirror over one distributed record hosted by this node.
|
||||
/// </summary>
|
||||
public Task<CultMeshReactiveDocument<TDocument>> ReactiveDocumentAsync<TDocument>(
|
||||
string recordKey,
|
||||
CultMeshVerse verse,
|
||||
CultMeshReactiveDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return ReactiveDocumentAsync<TDocument>(recordKey, verse.Context, options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
@ -1457,17 +1566,15 @@ namespace GameCult.Mesh
|
|||
return Document<TDocument>(
|
||||
ResolveDocumentId(documentId, key),
|
||||
context,
|
||||
async _ => await ReadRequiredAsync<TDocument>(database, key).ConfigureAwait(false),
|
||||
_ => database.WatchRecord<TDocument>(key)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
async _ => await ReadDatabaseDocumentRequiredAsync<TDocument>(database, key).ConfigureAwait(false),
|
||||
_ => WatchDatabaseRecordAs<TDocument>(database, key),
|
||||
async value =>
|
||||
{
|
||||
await database.PutAsync(key, value).ConfigureAwait(false);
|
||||
await PutDatabaseDocumentAsync(database, key, value, predicted: false).ConfigureAwait(false);
|
||||
},
|
||||
async value =>
|
||||
{
|
||||
await database.PutPredictedAsync(key, value).ConfigureAwait(false);
|
||||
await PutDatabaseDocumentAsync(database, key, value, predicted: true).ConfigureAwait(false);
|
||||
},
|
||||
sourceList,
|
||||
route);
|
||||
|
|
@ -2159,44 +2266,44 @@ namespace GameCult.Mesh
|
|||
where TDocument : class
|
||||
{
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
return await FetchSnapshotAsync(
|
||||
createClient,
|
||||
endpoint,
|
||||
ToSnapshotRequestOptions(
|
||||
options,
|
||||
context,
|
||||
options.SchemaIds ?? new[] { descriptor.SchemaId },
|
||||
new[] { recordKey }))
|
||||
return await FetchSnapshotAsync(
|
||||
createClient,
|
||||
endpoint,
|
||||
ToSnapshotRequestOptions(
|
||||
options,
|
||||
context,
|
||||
options.SchemaIds ?? new[] { descriptor.SchemaId },
|
||||
new[] { recordKey }))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions(
|
||||
private static CultMeshSnapshotRequestOptions ToSnapshotRequestOptions(
|
||||
CultMeshPeerSnapshotDocumentOptions options,
|
||||
CultMeshQueryContext context,
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
IReadOnlyList<string>? recordKeys)
|
||||
CultMeshQueryContext context,
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
IReadOnlyList<string>? recordKeys)
|
||||
{
|
||||
return new CultMeshSnapshotRequestOptions
|
||||
return new CultMeshSnapshotRequestOptions
|
||||
{
|
||||
SchemaIds = schemaIds,
|
||||
RecordKeys = recordKeys,
|
||||
ShardId = options.ShardId,
|
||||
ShardEpoch = options.ShardEpoch,
|
||||
ResponseTimeout = options.ResponseTimeout,
|
||||
ConnectTimeout = options.ConnectTimeout,
|
||||
MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix)
|
||||
? $"cultmesh:{context.RuntimeId}:snapshot"
|
||||
: options.MessageIdPrefix,
|
||||
Security = options.Security,
|
||||
ConfigureClient = options.ConfigureClient,
|
||||
RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId)
|
||||
? context.RuntimeId
|
||||
: options.RudpRuntimeId,
|
||||
RudpConnectionId = options.RudpConnectionId,
|
||||
RudpConnectPayload = options.RudpConnectPayload,
|
||||
RudpMaxFragmentBytes = options.RudpMaxFragmentBytes,
|
||||
RudpResendDelayMs = options.RudpResendDelayMs
|
||||
};
|
||||
SchemaIds = schemaIds,
|
||||
RecordKeys = recordKeys,
|
||||
ShardId = options.ShardId,
|
||||
ShardEpoch = options.ShardEpoch,
|
||||
ResponseTimeout = options.ResponseTimeout,
|
||||
ConnectTimeout = options.ConnectTimeout,
|
||||
MessageIdPrefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix)
|
||||
? $"cultmesh:{context.RuntimeId}:snapshot"
|
||||
: options.MessageIdPrefix,
|
||||
Security = options.Security,
|
||||
ConfigureClient = options.ConfigureClient,
|
||||
RudpRuntimeId = string.IsNullOrWhiteSpace(options.RudpRuntimeId)
|
||||
? context.RuntimeId
|
||||
: options.RudpRuntimeId,
|
||||
RudpConnectionId = options.RudpConnectionId,
|
||||
RudpConnectPayload = options.RudpConnectPayload,
|
||||
RudpMaxFragmentBytes = options.RudpMaxFragmentBytes,
|
||||
RudpResendDelayMs = options.RudpResendDelayMs
|
||||
};
|
||||
}
|
||||
|
||||
private static TDocument ReadDocumentFromSnapshotResponse<TDocument>(
|
||||
|
|
@ -2643,5 +2750,146 @@ namespace GameCult.Mesh
|
|||
?? throw new KeyNotFoundException(
|
||||
$"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}.");
|
||||
}
|
||||
|
||||
private static async Task<TDocument> ReadDatabaseDocumentRequiredAsync<TDocument>(
|
||||
CultNetDatabase database,
|
||||
CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
var document = await database.GetAsync<TDocument>(key).ConfigureAwait(false);
|
||||
if (document != null)
|
||||
return document;
|
||||
|
||||
var untyped = database.Cache.Get(key);
|
||||
if (untyped != null && IsSameCultDocumentSchema<TDocument>(untyped.GetType()))
|
||||
return ConvertUntypedDocument<TDocument>(untyped);
|
||||
|
||||
throw new KeyNotFoundException(
|
||||
$"CultMesh document '{key.Value}' was not found as {typeof(TDocument).FullName}.");
|
||||
}
|
||||
|
||||
private static R3.Observable<TDocument> WatchDatabaseRecordAs<TDocument>(
|
||||
CultNetDatabase database,
|
||||
CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
return database.WatchAllChanges()
|
||||
.Where(change => TryConvertDatabaseChange(change, key, descriptor, out TDocument? _))
|
||||
.Select(change =>
|
||||
{
|
||||
TryConvertDatabaseChange(change, key, descriptor, out TDocument? document);
|
||||
return document!;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryConvertDatabaseChange<TDocument>(
|
||||
object change,
|
||||
CultRecordKey key,
|
||||
CultDocumentDescriptor descriptor,
|
||||
out TDocument? document)
|
||||
where TDocument : class
|
||||
{
|
||||
document = null;
|
||||
if (change == null) return false;
|
||||
|
||||
var changeType = change.GetType();
|
||||
if (changeType.GetProperty("Key")?.GetValue(change) is not CultRecordKey changedKey ||
|
||||
!changedKey.Equals(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = changeType.GetProperty("Document")?.GetValue(change);
|
||||
if (current == null)
|
||||
return false;
|
||||
|
||||
var schemaId = changeType.GetProperty("SchemaId")?.GetValue(change) as string;
|
||||
if (!IsSameCultDocumentSchema(current.GetType(), descriptor, schemaId))
|
||||
return false;
|
||||
|
||||
document = current as TDocument ?? ConvertUntypedDocument<TDocument>(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsSameCultDocumentSchema<TDocument>(Type documentType)
|
||||
where TDocument : class
|
||||
{
|
||||
return IsSameCultDocumentSchema(
|
||||
documentType,
|
||||
CultDocumentRegistry.Shared.GetRequired<TDocument>());
|
||||
}
|
||||
|
||||
private static bool IsSameCultDocumentSchema(
|
||||
Type documentType,
|
||||
CultDocumentDescriptor descriptor,
|
||||
string? schemaId = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(schemaId) &&
|
||||
string.Equals(schemaId, descriptor.SchemaId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
CultDocumentDescriptor storedDescriptor;
|
||||
try
|
||||
{
|
||||
storedDescriptor = CultDocumentRegistry.Shared.GetRequired(documentType);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(storedDescriptor.SchemaName, descriptor.SchemaName, StringComparison.Ordinal) &&
|
||||
string.Equals(storedDescriptor.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static TDocument ConvertUntypedDocument<TDocument>(object document)
|
||||
where TDocument : class
|
||||
{
|
||||
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
|
||||
return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload);
|
||||
}
|
||||
|
||||
private static object ConvertUntypedDocument(object document, Type targetType)
|
||||
{
|
||||
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
|
||||
return CultDocumentMessagePackSerialization.DeserializeUntyped(targetType, payload);
|
||||
}
|
||||
|
||||
private static async Task PutDatabaseDocumentAsync<TDocument>(
|
||||
CultNetDatabase database,
|
||||
CultRecordKey key,
|
||||
TDocument value,
|
||||
bool predicted)
|
||||
where TDocument : class
|
||||
{
|
||||
var stored = database.Cache.Get(key);
|
||||
object outgoing = value;
|
||||
var outgoingType = typeof(TDocument);
|
||||
if (stored != null &&
|
||||
!outgoingType.IsInstanceOfType(stored) &&
|
||||
IsSameCultDocumentSchema(stored.GetType(), CultDocumentRegistry.Shared.GetRequired<TDocument>()))
|
||||
{
|
||||
outgoing = ConvertUntypedDocument(value, stored.GetType());
|
||||
outgoingType = stored.GetType();
|
||||
}
|
||||
|
||||
var method = typeof(CultNetDatabase)
|
||||
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.Where(candidate => candidate.Name == (predicted
|
||||
? nameof(CultNetDatabase.PutPredictedAsync)
|
||||
: nameof(CultNetDatabase.PutAsync)))
|
||||
.Single(candidate =>
|
||||
candidate.IsGenericMethodDefinition &&
|
||||
candidate.GetParameters() is { Length: 2 } parameters &&
|
||||
parameters[0].ParameterType == typeof(CultRecordKey));
|
||||
|
||||
var task = (Task)method
|
||||
.MakeGenericMethod(outgoingType)
|
||||
.Invoke(database, new object[] { key, outgoing })!;
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,73 @@ public sealed class CultMeshStreamingTests
|
|||
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("pilot-coalesced-order");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NodeReactiveDocument_OpensSameSchemaAliasWithOneCall()
|
||||
{
|
||||
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
||||
using var temp = new TemporaryDirectory();
|
||||
using var node = await CultMesh.CreateNodeAsync(
|
||||
Path.Combine(temp.Path, "node-reactive-alias.ccmp"),
|
||||
new CultMeshNodeOptions
|
||||
{
|
||||
StartServer = false,
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = "pilot-a",
|
||||
Shards = new[]
|
||||
{
|
||||
new CultNetShardDescriptor(
|
||||
"pilot-inputs",
|
||||
"server",
|
||||
epoch: 1,
|
||||
isPrimary: false,
|
||||
schemaIds: new[] { schemaId },
|
||||
keyPrefix: "input:")
|
||||
},
|
||||
ClientAuthorityScopes = new[]
|
||||
{
|
||||
new CultNetClientAuthorityScope(
|
||||
"pilot-a",
|
||||
schemaIds: new[] { schemaId },
|
||||
keyPrefix: "input:pilot-a")
|
||||
}
|
||||
}
|
||||
});
|
||||
var key = new CultRecordKey("input:pilot-a:node-reactive");
|
||||
await node.Cache.UpsertAsync(new MeshNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_note.v1",
|
||||
Text = "node-initial",
|
||||
Revision = 1
|
||||
}, new CultRecordHandle<MeshNoteDocument>(key));
|
||||
|
||||
using var pilotReactive = await node.ReactiveDocumentAsync<MeshNoteAliasDocument>(
|
||||
key,
|
||||
CultMesh.Verse("starbridge", "pilot-a"),
|
||||
new CultMeshReactiveDocumentOptions
|
||||
{
|
||||
FlushDelay = TimeSpan.FromMinutes(1)
|
||||
});
|
||||
var commanderHandle = node.Document<MeshNoteDocument>(
|
||||
key.Value,
|
||||
CultMesh.Verse("starbridge", "commander-rts"));
|
||||
var observedByCommander = new List<MeshNoteDocument>();
|
||||
using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add);
|
||||
|
||||
pilotReactive.Update(document =>
|
||||
{
|
||||
document.Text = "node-one-call-prediction";
|
||||
document.Revision = 2;
|
||||
});
|
||||
await pilotReactive.FlushAsync();
|
||||
|
||||
await WaitForAsync(() => observedByCommander.Any(document => document.Text == "node-one-call-prediction"));
|
||||
pilotReactive.Document.DocumentId.Should().Be(key.Value);
|
||||
pilotReactive.Document.Context.RuntimeId.Should().Be("pilot-a");
|
||||
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("node-one-call-prediction");
|
||||
(await commanderHandle.AsSchemaAlias<MeshNoteAliasDocument>().LatestAsync()).Revision.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReactiveDocument_SyncedSnapshotEndpointCapturesCanonicalCorrectionAcrossRuntimeBoundary()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue