Db: Migrate markers to db

rendering
Liza 2023-02-17 18:36:22 +01:00
parent 57a5be7938
commit e624c5b628
13 changed files with 582 additions and 12 deletions

View File

@ -28,8 +28,6 @@ namespace Pal.Client.Configuration
_logger = logger; _logger = logger;
_pluginInterface = pluginInterface; _pluginInterface = pluginInterface;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
Migrate();
} }
private string ConfigPath => Path.Join(_pluginInterface.GetPluginConfigDirectory(), "palace-pal.config.json"); private string ConfigPath => Path.Join(_pluginInterface.GetPluginConfigDirectory(), "palace-pal.config.json");
@ -54,7 +52,7 @@ namespace Pal.Client.Configuration
#pragma warning disable CS0612 #pragma warning disable CS0612
#pragma warning disable CS0618 #pragma warning disable CS0618
private void Migrate() public void Migrate()
{ {
if (_pluginInterface.ConfigFile.Exists) if (_pluginInterface.ConfigFile.Exists)
{ {

View File

@ -18,18 +18,18 @@ namespace Pal.Client.Configuration.Legacy
private static readonly JsonSerializerOptions JsonSerializerOptions = new() { IncludeFields = true }; private static readonly JsonSerializerOptions JsonSerializerOptions = new() { IncludeFields = true };
private const int CurrentVersion = 4; private const int CurrentVersion = 4;
private static string _pluginConfigDirectory; private static string _pluginConfigDirectory = null!;
private static EMode _mode = EMode.Online; // might not be true, but this is 'less strict filtering' for migrations private static readonly EMode _mode = EMode.Online; // might not be true, but this is 'less strict filtering' for migrations
internal static void SetContextProperties(string pluginConfigDirectory) internal static void SetContextProperties(string pluginConfigDirectory)
{ {
_pluginConfigDirectory = pluginConfigDirectory; _pluginConfigDirectory = pluginConfigDirectory;
} }
public uint TerritoryType { get; set; } public ushort TerritoryType { get; set; }
public ConcurrentBag<JsonMarker> Markers { get; set; } = new(); public ConcurrentBag<JsonMarker> Markers { get; set; } = new();
public JsonFloorState(uint territoryType) public JsonFloorState(ushort territoryType)
{ {
TerritoryType = territoryType; TerritoryType = territoryType;
} }
@ -44,7 +44,7 @@ namespace Pal.Client.Configuration.Legacy
Markers = new ConcurrentBag<JsonMarker>(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0)); Markers = new ConcurrentBag<JsonMarker>(Markers.Where(x => x.Seen || !x.WasImported || x.Imports.Count > 0));
} }
public static JsonFloorState? Load(uint territoryType) public static JsonFloorState? Load(ushort territoryType)
{ {
string path = GetSaveLocation(territoryType); string path = GetSaveLocation(territoryType);
if (!File.Exists(path)) if (!File.Exists(path))
@ -136,6 +136,10 @@ namespace Pal.Client.Configuration.Legacy
{ {
foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues()) foreach (ETerritoryType territory in typeof(ETerritoryType).GetEnumValues())
{ {
// we never had markers for eureka orthos, so don't bother
if (territory > ETerritoryType.HeavenOnHigh_91_100)
break;
JsonFloorState? localState = Load((ushort)territory); JsonFloorState? localState = Load((ushort)territory);
if (localState != null) if (localState != null)
action(localState); action(localState);

View File

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Pal.Client.Database;
using Pal.Common;
namespace Pal.Client.Configuration.Legacy
{
/// <summary>
/// Imports legacy territoryType.json files into the database if it exists, and no markers for that territory exist.
/// </summary>
internal sealed class JsonMigration
{
private readonly ILogger<JsonMigration> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly DalamudPluginInterface _pluginInterface;
public JsonMigration(ILogger<JsonMigration> logger, IServiceScopeFactory serviceScopeFactory,
DalamudPluginInterface pluginInterface)
{
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_pluginInterface = pluginInterface;
}
#pragma warning disable CS0612
public async Task MigrateAsync(CancellationToken cancellationToken)
{
List<JsonFloorState> floorsToMigrate = new();
JsonFloorState.ForEach(floorsToMigrate.Add);
if (floorsToMigrate.Count == 0)
{
_logger.LogInformation("Found no floors to migrate");
return;
}
cancellationToken.ThrowIfCancellationRequested();
await using var scope = _serviceScopeFactory.CreateAsyncScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
var fileStream = new FileStream(
Path.Join(_pluginInterface.GetPluginConfigDirectory(),
$"territory-backup-{DateTime.Now:yyyyMMdd-HHmmss}.zip"),
FileMode.CreateNew);
using (var backup = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{
IReadOnlyDictionary<Guid, ImportHistory> imports =
await dbContext.Imports.ToDictionaryAsync(import => import.Id, cancellationToken);
foreach (var floorToMigrate in floorsToMigrate)
{
backup.CreateEntryFromFile(floorToMigrate.GetSaveLocation(),
Path.GetFileName(floorToMigrate.GetSaveLocation()), CompressionLevel.SmallestSize);
await MigrateFloor(dbContext, floorToMigrate, imports, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
_logger.LogInformation("Removing {Count} old json files", floorsToMigrate.Count);
foreach (var floorToMigrate in floorsToMigrate)
File.Delete(floorToMigrate.GetSaveLocation());
}
/// <returns>Whether to archive this file once complete</returns>
private async Task MigrateFloor(
PalClientContext dbContext,
JsonFloorState floorToMigrate,
IReadOnlyDictionary<Guid, ImportHistory> imports,
CancellationToken cancellationToken)
{
using var logScope = _logger.BeginScope($"Import {(ETerritoryType)floorToMigrate.TerritoryType}");
if (floorToMigrate.Markers.Count == 0)
{
_logger.LogInformation("Skipping migration, floor has no markers");
}
if (await dbContext.Locations.AnyAsync(o => o.TerritoryType == floorToMigrate.TerritoryType,
cancellationToken))
{
_logger.LogInformation("Skipping migration, floor already has locations in the database");
return;
}
_logger.LogInformation("Starting migration of {Count} locations", floorToMigrate.Markers.Count);
List<ClientLocation> clientLocations = floorToMigrate.Markers
.Where(o => o.Type == JsonMarker.EType.Trap || o.Type == JsonMarker.EType.Hoard)
.Select(o =>
{
var clientLocation = new ClientLocation
{
TerritoryType = floorToMigrate.TerritoryType,
Type = MapJsonType(o.Type),
X = o.Position.X,
Y = o.Position.Y,
Z = o.Position.Z,
Seen = o.Seen,
// the SelectMany is misleading here, each import has either 0 or 1 associated db entry with that id
ImportedBy = o.Imports
.Select(importId =>
imports.TryGetValue(importId, out ImportHistory? import) ? import : null)
.Where(import => import != null)
.Cast<ImportHistory>()
.Distinct()
.ToList(),
};
clientLocation.RemoteEncounters = o.RemoteSeenOn
.Select(accountId => new RemoteEncounter(clientLocation, accountId))
.ToList();
return clientLocation;
}).ToList();
await dbContext.Locations.AddRangeAsync(clientLocations, cancellationToken);
_logger.LogInformation("Migrated {Count} locations", clientLocations.Count);
}
private ClientLocation.EType MapJsonType(JsonMarker.EType type)
{
return type switch
{
JsonMarker.EType.Trap => ClientLocation.EType.Trap,
JsonMarker.EType.Hoard => ClientLocation.EType.Hoard,
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
};
}
#pragma warning restore CS0612
}
}

View File

@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Pal.Client.Database
{
internal sealed class ClientLocation
{
[Key] public int LocalId { get; set; }
public ushort TerritoryType { get; set; }
public EType Type { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
/// <summary>
/// Whether we have encountered the trap/coffer at this location in-game.
/// </summary>
public bool Seen { get; set; }
/// <summary>
/// Which account ids this marker was seen. This is a list merely to support different remote endpoints
/// (where each server would assign you a different id).
/// </summary>
public List<RemoteEncounter> RemoteEncounters { get; set; } = new();
/// <summary>
/// To keep track of which markers were imported through a downloaded file, we save the associated import-id.
///
/// Importing another file for the same remote server will remove the old import-id, and add the new import-id here.
/// </summary>
public List<ImportHistory> ImportedBy { get; set; } = new();
public enum EType
{
Trap = 1,
Hoard = 2,
}
}
}

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace Pal.Client.Database namespace Pal.Client.Database
{ {
@ -8,5 +9,7 @@ namespace Pal.Client.Database
public string? RemoteUrl { get; set; } public string? RemoteUrl { get; set; }
public DateTime ExportedAt { get; set; } public DateTime ExportedAt { get; set; }
public DateTime ImportedAt { get; set; } public DateTime ImportedAt { get; set; }
public List<ClientLocation> ImportedLocations { get; set; } = new();
} }
} }

View File

@ -0,0 +1,136 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Pal.Client.Database;
#nullable disable
namespace Pal.Client.Database.Migrations
{
[DbContext(typeof(PalClientContext))]
[Migration("20230217160342_AddClientLocations")]
partial class AddClientLocations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.Property<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations");
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("ExportedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedAt")
.HasColumnType("TEXT");
b.Property<string>("RemoteUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Imports");
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("ClientLocationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ClientLocationId");
b.ToTable("RemoteEncounters");
});
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.HasOne("Pal.Client.Database.ImportHistory", null)
.WithMany()
.HasForeignKey("ImportedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Pal.Client.Database.ClientLocation", null)
.WithMany()
.HasForeignKey("ImportedLocationsLocalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation")
.WithMany()
.HasForeignKey("ClientLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClientLocation");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Pal.Client.Database.Migrations
{
/// <inheritdoc />
public partial class AddClientLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Locations",
columns: table => new
{
LocalId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TerritoryType = table.Column<ushort>(type: "INTEGER", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
X = table.Column<float>(type: "REAL", nullable: false),
Y = table.Column<float>(type: "REAL", nullable: false),
Z = table.Column<float>(type: "REAL", nullable: false),
Seen = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Locations", x => x.LocalId);
});
migrationBuilder.CreateTable(
name: "LocationImports",
columns: table => new
{
ImportedById = table.Column<Guid>(type: "TEXT", nullable: false),
ImportedLocationsLocalId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocationImports", x => new { x.ImportedById, x.ImportedLocationsLocalId });
table.ForeignKey(
name: "FK_LocationImports_Imports_ImportedById",
column: x => x.ImportedById,
principalTable: "Imports",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LocationImports_Locations_ImportedLocationsLocalId",
column: x => x.ImportedLocationsLocalId,
principalTable: "Locations",
principalColumn: "LocalId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RemoteEncounters",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ClientLocationId = table.Column<int>(type: "INTEGER", nullable: false),
AccountId = table.Column<string>(type: "TEXT", maxLength: 13, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RemoteEncounters", x => x.Id);
table.ForeignKey(
name: "FK_RemoteEncounters_Locations_ClientLocationId",
column: x => x.ClientLocationId,
principalTable: "Locations",
principalColumn: "LocalId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_LocationImports_ImportedLocationsLocalId",
table: "LocationImports",
column: "ImportedLocationsLocalId");
migrationBuilder.CreateIndex(
name: "IX_RemoteEncounters_ClientLocationId",
table: "RemoteEncounters",
column: "ClientLocationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LocationImports");
migrationBuilder.DropTable(
name: "RemoteEncounters");
migrationBuilder.DropTable(
name: "Locations");
}
}
}

View File

@ -17,6 +17,50 @@ namespace Pal.Client.Database.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.Property<Guid>("ImportedById")
.HasColumnType("TEXT");
b.Property<int>("ImportedLocationsLocalId")
.HasColumnType("INTEGER");
b.HasKey("ImportedById", "ImportedLocationsLocalId");
b.HasIndex("ImportedLocationsLocalId");
b.ToTable("LocationImports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ClientLocation", b =>
{
b.Property<int>("LocalId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.Property<ushort>("TerritoryType")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<float>("Z")
.HasColumnType("REAL");
b.HasKey("LocalId");
b.ToTable("Locations", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.ImportHistory", b => modelBuilder.Entity("Pal.Client.Database.ImportHistory", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -34,7 +78,54 @@ namespace Pal.Client.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Imports"); b.ToTable("Imports", (string)null);
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("TEXT");
b.Property<int>("ClientLocationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ClientLocationId");
b.ToTable("RemoteEncounters", (string)null);
});
modelBuilder.Entity("ClientLocationImportHistory", b =>
{
b.HasOne("Pal.Client.Database.ImportHistory", null)
.WithMany()
.HasForeignKey("ImportedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Pal.Client.Database.ClientLocation", null)
.WithMany()
.HasForeignKey("ImportedLocationsLocalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Pal.Client.Database.RemoteEncounter", b =>
{
b.HasOne("Pal.Client.Database.ClientLocation", "ClientLocation")
.WithMany()
.HasForeignKey("ClientLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ClientLocation");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@ -4,11 +4,21 @@ namespace Pal.Client.Database
{ {
internal class PalClientContext : DbContext internal class PalClientContext : DbContext
{ {
public DbSet<ClientLocation> Locations { get; set; } = null!;
public DbSet<ImportHistory> Imports { get; set; } = null!; public DbSet<ImportHistory> Imports { get; set; } = null!;
public DbSet<RemoteEncounter> RemoteEncounters { get; set; } = null!;
public PalClientContext(DbContextOptions<PalClientContext> options) public PalClientContext(DbContextOptions<PalClientContext> options)
: base(options) : base(options)
{ {
} }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClientLocation>()
.HasMany(o => o.ImportedBy)
.WithMany(o => o.ImportedLocations)
.UsingEntity(o => o.ToTable("LocationImports"));
}
} }
} }

View File

@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using Pal.Client.Extensions;
using Pal.Client.Net;
namespace Pal.Client.Database
{
/// <summary>
/// To avoid sending too many requests to the server, we cache which locations have been seen
/// locally. These never expire, and locations which have been seen with a specific account
/// are never sent to the server again.
///
/// To be marked as seen, it needs to be essentially processed by <see cref="RemoteApi.MarkAsSeen"/>.
/// </summary>
internal sealed class RemoteEncounter
{
[Key]
public int Id { get; private set; }
public int ClientLocationId { get; private set; }
public ClientLocation ClientLocation { get; private set; } = null!;
/// <summary>
/// Partial account id. This is partially unique - however problems would (in theory)
/// only occur once you have two account-ids where the first 13 characters are equal.
/// </summary>
[MaxLength(13)]
public string AccountId { get; private set; }
private RemoteEncounter(int clientLocationId, string accountId)
{
ClientLocationId = clientLocationId;
AccountId = accountId;
}
public RemoteEncounter(ClientLocation clientLocation, string accountId)
{
ClientLocation = clientLocation;
AccountId = accountId.ToPartialId();
}
}
}

View File

@ -96,6 +96,7 @@ namespace Pal.Client
_sqliteConnectionString = _sqliteConnectionString =
$"Data Source={Path.Join(pluginInterface.GetPluginConfigDirectory(), "palace-pal.data.sqlite3")}"; $"Data Source={Path.Join(pluginInterface.GetPluginConfigDirectory(), "palace-pal.data.sqlite3")}";
services.AddDbContext<PalClientContext>(o => o.UseSqlite(_sqliteConnectionString)); services.AddDbContext<PalClientContext>(o => o.UseSqlite(_sqliteConnectionString));
services.AddTransient<JsonMigration>();
// plugin-specific // plugin-specific
services.AddSingleton<Plugin>(); services.AddSingleton<Plugin>();
@ -175,13 +176,19 @@ namespace Pal.Client
{ {
_logger.LogInformation("Loading database & running migrations"); _logger.LogInformation("Loading database & running migrations");
await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>(); await using var dbContext = scope.ServiceProvider.GetRequiredService<PalClientContext>();
await dbContext.Database.MigrateAsync(); await dbContext.Database.MigrateAsync(token);
_logger.LogInformation("Completed database migrations"); _logger.LogInformation("Completed database migrations");
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
// v1 migration: config migration for import history, json migration for markers
_serviceProvider.GetRequiredService<ConfigurationManager>().Migrate();
await _serviceProvider.GetRequiredService<JsonMigration>().MigrateAsync(token);
token.ThrowIfCancellationRequested();
// windows that have logic to open on startup // windows that have logic to open on startup
_serviceProvider.GetRequiredService<AgreementWindow>(); _serviceProvider.GetRequiredService<AgreementWindow>();

View File

@ -17,7 +17,7 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DebugType>portable</DebugType> <DebugType>portable</DebugType>
<PathMap>$(SolutionDir)=X:\</PathMap> <PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'"> <PropertyGroup Condition="'$(Configuration)' == 'Release'">

View File

@ -6,6 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DebugType>portable</DebugType> <DebugType>portable</DebugType>
<PathMap>$(SolutionDir)=X:\</PathMap> <PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
</PropertyGroup> </PropertyGroup>
</Project> </Project>