diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index a8dc540a..73392101 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -68,6 +68,8 @@ namespace PluralKit.Bot .AddTransient() .AddTransient() .AddTransient() + .AddTransient() + .AddSingleton() .AddTransient() diff --git a/PluralKit.Bot/Commands/ImportExportCommands.cs b/PluralKit.Bot/Commands/ImportExportCommands.cs new file mode 100644 index 00000000..4b54b060 --- /dev/null +++ b/PluralKit.Bot/Commands/ImportExportCommands.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Discord.Commands; +using Newtonsoft.Json; + +namespace PluralKit.Bot.Commands +{ + public class ImportExportCommands : ModuleBase + { + public DataFileService DataFiles { get; set; } + + [Command("import")] + [Remarks("import [fileurl]")] + public async Task Import([Remainder] string url = null) + { + if (url == null) url = Context.Message.Attachments.FirstOrDefault()?.Filename; + if (url == null) throw Errors.NoImportFilePassed; + + await Context.BusyIndicator(async () => + { + using (var client = new HttpClient()) + { + var response = await client.GetAsync(url); + if (!response.IsSuccessStatusCode) throw Errors.InvalidImportFile; + var str = await response.Content.ReadAsStringAsync(); + + var data = TryDeserialize(str); + if (!data.HasValue || !data.Value.Valid) throw Errors.InvalidImportFile; + + if (Context.SenderSystem != null && Context.SenderSystem.Hid != data.Value.Id) + { + // TODO: prompt "are you sure you want to import someone else's system? + } + + // If passed system is null, it'll create a new one + // (and that's okay!) + var result = await DataFiles.ImportSystem(data.Value, Context.SenderSystem); + + if (Context.SenderSystem == null) + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.System.Hid}`. Type `pk;system` for more information."); + } + else + { + await Context.Channel.SendMessageAsync($"{Emojis.Success} Updated {result.ModifiedNames.Count} members, created {result.AddedNames.Count} members. Type `pk;system list` to check!"); + } + } + }); + } + + [Command("export")] + [Remarks("export")] + [MustHaveSystem] + public async Task Export() + { + await Context.BusyIndicator(async () => + { + var data = await DataFiles.ExportSystem(Context.SenderSystem); + var json = JsonConvert.SerializeObject(data, Formatting.None); + + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await Context.Channel.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); + }); + } + + private DataFileSystem? TryDeserialize(string json) + { + try + { + return JsonConvert.DeserializeObject(json); + } + catch (JsonException e) + { + Console.WriteLine("uww"); + } + + return null; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 5a37569b..cce4dff3 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -60,7 +60,8 @@ namespace PluralKit.Bot { public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr}'. To find your time zone ID, use the following website: "); public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled."); - public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: "); + public static Exception NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command."); + public static Exception InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox."); } } \ No newline at end of file diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/DataFiles.cs new file mode 100644 index 00000000..52ecb91f --- /dev/null +++ b/PluralKit.Core/DataFiles.cs @@ -0,0 +1,223 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NodaTime; +using NodaTime.Text; +using NodaTime.TimeZones; + +namespace PluralKit.Bot +{ + public class DataFileService + { + private SystemStore _systems; + private MemberStore _members; + private SwitchStore _switches; + + public DataFileService(SystemStore systems, MemberStore members, SwitchStore switches) + { + _systems = systems; + _members = members; + _switches = switches; + } + + public async Task ExportSystem(PKSystem system) + { + var members = new List(); + foreach (var member in await _members.GetBySystem(system)) members.Add(await ExportMember(member)); + + var switches = new List(); + foreach (var sw in await _switches.GetSwitches(system, 999999)) switches.Add(await ExportSwitch(sw)); + + return new DataFileSystem + { + Id = system.Hid, + Name = system.Name, + Description = system.Description, + Tag = system.Tag, + AvatarUrl = system.AvatarUrl, + TimeZone = system.UiTz, + Members = members, + Switches = switches, + Created = system.Created.ToString(Formats.TimestampExportFormat, null), + LinkedAccounts = (await _systems.GetLinkedAccountIds(system)).ToList() + }; + } + + private async Task ExportMember(PKMember member) => new DataFileMember + { + Id = member.Hid, + Name = member.Name, + Description = member.Description, + Birthdate = member.Birthday?.ToString(Formats.DateExportFormat, null), + Pronouns = member.Pronouns, + Color = member.Color, + AvatarUrl = member.AvatarUrl, + Prefix = member.Prefix, + Suffix = member.Suffix, + Created = member.Created.ToString(Formats.TimestampExportFormat, null), + MessageCount = await _members.MessageCount(member) + }; + + private async Task ExportSwitch(PKSwitch sw) => new DataFileSwitch + { + Members = (await _switches.GetSwitchMembers(sw)).Select(m => m.Hid).ToList(), + Timestamp = sw.Timestamp.ToString(Formats.TimestampExportFormat, null) + }; + + public async Task ImportSystem(DataFileSystem data, PKSystem system) + { + var result = new ImportResult { AddedNames = new List(), ModifiedNames = new List() }; + + // If we don't already have a system to save to, create one + if (system == null) system = await _systems.Create(data.Name); + + // Apply system info + system.Name = data.Name; + system.Description = data.Description; + system.Tag = data.Tag; + system.AvatarUrl = data.AvatarUrl; + system.UiTz = data.TimeZone ?? "UTC"; + await _systems.Save(system); + + // Apply members + // TODO: parallelize? + foreach (var dataMember in data.Members) + { + // If member's given an ID, we try to look up the member with the given ID + PKMember member = null; + if (dataMember.Id != null) + { + member = await _members.GetByHid(dataMember.Id); + + // ...but if it's a different system's member, we just make a new one anyway + if (member != null && member.System != system.Id) member = null; + } + + // Try to look up by name, too + if (member == null) member = await _members.GetByName(system, dataMember.Name); + + // And if all else fails (eg. fresh import from Tupperbox, etc) we just make a member lol + if (member == null) + { + member = await _members.Create(system, dataMember.Name); + result.AddedNames.Add(dataMember.Name); + } + else + { + result.ModifiedNames.Add(dataMember.Name); + } + + // Apply member info + member.Name = dataMember.Name; + member.Description = dataMember.Description; + member.Color = dataMember.Color; + member.AvatarUrl = dataMember.AvatarUrl; + member.Prefix = dataMember.Prefix; + member.Suffix = dataMember.Suffix; + + var birthdayParse = LocalDatePattern.CreateWithInvariantCulture(Formats.DateExportFormat).Parse(dataMember.Birthdate); + member.Birthday = birthdayParse.Success ? (LocalDate?) birthdayParse.Value : null; + await _members.Save(member); + } + + // TODO: import switches, too? + + result.System = system; + return result; + } + } + + public struct ImportResult + { + public ICollection AddedNames; + public ICollection ModifiedNames; + public PKSystem System; + } + + public struct DataFileSystem + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("tag")] + public string Tag; + + [JsonProperty("avatar_url")] + public string AvatarUrl; + + [JsonProperty("timezone")] + public string TimeZone; + + [JsonProperty("members")] + public ICollection Members; + + [JsonProperty("switches")] + public ICollection Switches; + + [JsonProperty("accounts")] + public ICollection LinkedAccounts; + + [JsonProperty("created")] + public string Created; + + private bool TimeZoneValid => TimeZone == null || DateTimeZoneProviders.Tzdb.GetZoneOrNull(TimeZone) != null; + + [JsonIgnore] + public bool Valid => TimeZoneValid && Members.All(m => m.Valid); + } + + public struct DataFileMember + { + [JsonProperty("id")] + public string Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("birthday")] + public string Birthdate; + + [JsonProperty("pronouns")] + public string Pronouns; + + [JsonProperty("color")] + public string Color; + + [JsonProperty("avatar_url")] + public string AvatarUrl; + + [JsonProperty("prefix")] + public string Prefix; + + [JsonProperty("suffix")] + public string Suffix; + + [JsonProperty("message_count")] + public int MessageCount; + + [JsonProperty("created")] + public string Created; + + [JsonIgnore] + public bool Valid => Name != null; + } + + public struct DataFileSwitch + { + [JsonProperty("timestamp")] + public string Timestamp; + + [JsonProperty("members")] + public ICollection Members; + } +} \ No newline at end of file diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 817f3249..ce714be0 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -15,4 +15,10 @@ + + + ..\..\..\.nuget\packages\newtonsoft.json\11.0.2\lib\netstandard2.0\Newtonsoft.Json.dll + + + diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Stores.cs index 98a787a2..14cb6483 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Stores.cs @@ -84,7 +84,7 @@ namespace PluralKit { public async Task GetByName(PKSystem system, string name) { // QueryFirst, since members can (in rare cases) share names - return await conn.QueryFirstOrDefaultAsync("select * from members where lower(name) = @Name and system = @SystemID", new { Name = name, SystemID = system.Id }); + return await conn.QueryFirstOrDefaultAsync("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id }); } public async Task> GetUnproxyableMembers(PKSystem system) { diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs index 72867783..35c75b0d 100644 --- a/PluralKit.Core/Utils.cs +++ b/PluralKit.Core/Utils.cs @@ -224,6 +224,8 @@ namespace PluralKit public static class Formats { public static string DateTimeFormat = "yyyy-MM-dd HH:mm:ss"; + public static string DateExportFormat = "yyyy-MM-dd"; + public static string TimestampExportFormat = "g"; public static string DurationFormat = "D'd' h'h' m'm' s's'"; } } \ No newline at end of file