Files
lampac/mods/DatabaseEditor/DatabaseStore.cs
T

1513 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Data.Sqlite;
using Shared.Services;
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace DatabaseEditor;
public sealed class SaveRecordRequest
{
public string database { get; set; }
public long? id { get; set; }
public string user { get; set; }
public string card { get; set; }
public string item { get; set; }
public string data { get; set; }
}
public sealed class DeleteRecordRequest
{
public string database { get; set; }
public long id { get; set; }
}
public sealed class BackupRequest
{
public string database { get; set; }
}
public sealed class RestoreBackupRequest
{
public string database { get; set; }
public string file { get; set; }
}
public sealed class RenameUserRequest
{
public string oldUser { get; set; }
public string newUser { get; set; }
}
public sealed class RenameUserResult
{
public string oldUser { get; set; }
public string newUser { get; set; }
public int syncRecords { get; set; }
public int timecodeRecords { get; set; }
}
public sealed class DeleteUserRequest
{
public string user { get; set; }
}
public sealed class DeleteUserResult
{
public string user { get; set; }
public int syncRecords { get; set; }
public int timecodeRecords { get; set; }
}
public sealed class SaveSyncItemRequest
{
public long recordId { get; set; }
public string cardId { get; set; }
public string[] categories { get; set; }
}
public sealed class DeleteSyncItemRequest
{
public long recordId { get; set; }
public string cardId { get; set; }
}
public sealed class DatabaseSummary
{
public string database { get; set; }
public string title { get; set; }
public string file { get; set; }
public bool available { get; set; }
public long records { get; set; }
public long bytes { get; set; }
public string updated { get; set; }
}
public sealed class DatabaseUserOption
{
public string user { get; set; }
public long records { get; set; }
}
public sealed class DatabaseRecord
{
public long id { get; set; }
public string user { get; set; }
public string card { get; set; }
public string item { get; set; }
public string data { get; set; }
public string preview { get; set; }
public long dataLength { get; set; }
public string updated { get; set; }
public string title { get; set; }
public string poster { get; set; }
public string year { get; set; }
public string mediaType { get; set; }
public int? season { get; set; }
public int? episode { get; set; }
public double? position { get; set; }
public double? duration { get; set; }
public double? percent { get; set; }
}
public sealed class SyncUserItem
{
public string cardId { get; set; }
public string title { get; set; }
public string poster { get; set; }
public string year { get; set; }
public string mediaType { get; set; }
public List<string> categories { get; set; }
public int order { get; set; }
public Dictionary<string, int> categoryOrder { get; set; }
}
public sealed class SyncUserDetails
{
public long id { get; set; }
public string user { get; set; }
public string updated { get; set; }
public int total { get; set; }
public List<SyncUserItem> items { get; set; }
}
public sealed class DatabaseBackupResult
{
public string database { get; set; }
public string path { get; set; }
}
public sealed class DatabaseBackupFile
{
public string database { get; set; }
public string file { get; set; }
public string path { get; set; }
public long bytes { get; set; }
public string created { get; set; }
}
public sealed class DatabaseRestoreResult
{
public string database { get; set; }
public string restoredFrom { get; set; }
public string safetyBackup { get; set; }
}
public sealed class RecordsPage
{
public string database { get; set; }
public int page { get; set; }
public int pageSize { get; set; }
public long total { get; set; }
public int pages { get; set; }
public List<DatabaseRecord> records { get; set; }
}
public sealed class DatabaseEditorValidationException : Exception
{
public DatabaseEditorValidationException(string message) : base(message) { }
}
public sealed class DatabaseEditorConflictException : Exception
{
public DatabaseEditorConflictException(string message) : base(message) { }
}
public sealed class DatabaseEditorBusyException : Exception
{
public DatabaseEditorBusyException(string message) : base(message) { }
}
static class DatabaseStore
{
const int MaxDataBytes = 8 * 1024 * 1024;
const int MaxKeyLength = 512;
public static readonly string[] SyncCategories =
{
"history", "like", "watch", "wath", "book", "look",
"viewed", "scheduled", "continued", "thrown"
};
public static readonly string[] SyncEditorCategories =
{
"book", "like", "wath", "history",
"look", "viewed", "scheduled", "continued", "thrown"
};
public static readonly string[] SyncStatusCategories =
{
"look", "viewed", "scheduled", "continued", "thrown"
};
sealed class DatabaseSpec
{
public string key;
public string title;
public string path;
public string table;
public string semaphore;
public bool timecode;
/// <summary>Колонка, по которой сортируем и берём MAX.</summary>
public string updatedColumn = "updated";
/// <summary>Как показать её строкой: у TimeCode это мс, а не ISO-текст.</summary>
public Func<string, string> updatedText = expression => expression;
}
/// <summary>
/// TimeCode хранит таймкод типизированными колонками, а редактор правит road-объект Lampa —
/// тот же контракт, что у легаси `/timecode/all`. Собираем его на стороне SQLite.
/// </summary>
const string TimeCodeRoad = "json_object('duration', duration, 'time', position, 'percent', percent, 'profile', profile, 'updated', watched_at)";
static string TimeCodeStamp(string expression) => $"strftime('%Y-%m-%dT%H:%M:%SZ', {expression} / 1000, 'unixepoch')";
/// <summary>Обратная операция к <see cref="TimeCodeRoad"/>: road из редактора → колонки.</summary>
const string TimeCodeColumns = "position, duration, percent, profile, watched_at";
const string TimeCodeValues =
"COALESCE(json_extract(@data, '$.time'), 0), " +
"COALESCE(json_extract(@data, '$.duration'), 0), " +
"COALESCE(json_extract(@data, '$.percent'), 0), " +
"COALESCE(json_extract(@data, '$.profile'), 0), " +
"COALESCE(json_extract(@data, '$.updated'), 0)";
const string TimeCodeAssignments =
"position = COALESCE(json_extract(@data, '$.time'), 0), " +
"duration = COALESCE(json_extract(@data, '$.duration'), 0), " +
"percent = COALESCE(json_extract(@data, '$.percent'), 0), " +
"profile = COALESCE(json_extract(@data, '$.profile'), 0), " +
"watched_at = COALESCE(json_extract(@data, '$.updated'), 0)";
sealed class MediaMetadata
{
public string title;
public string poster;
public string year;
public string mediaType;
public List<string> hashTitles;
public int seasons;
public int episodes;
}
static readonly DatabaseSpec Sync = new()
{
key = "sync",
title = "Sync / закладки",
path = Path.Combine("database", "Sync.sql"),
table = "bookmarks",
semaphore = "Sync"
};
static readonly DatabaseSpec TimeCode = new()
{
key = "timecode",
title = "TimeCode / позиции",
path = Path.Combine("database", "TimeCode.sql"),
table = "timecodes",
semaphore = "TimeCode",
timecode = true,
updatedColumn = "updated_at",
updatedText = TimeCodeStamp
};
public static async Task<List<DatabaseSummary>> GetSummaryAsync()
{
var result = new List<DatabaseSummary>(2);
result.Add(await ReadSummaryAsync(TimeCode));
result.Add(await ReadSummaryAsync(Sync));
return result;
}
public static async Task<List<DatabaseUserOption>> GetUsersAsync(string database)
{
DatabaseSpec spec = GetSpec(database);
await using var connection = await OpenAsync(spec);
await using var command = connection.CreateCommand();
command.CommandText = $"SELECT user, COUNT(*) FROM {spec.table} WHERE user IS NOT NULL AND TRIM(user) <> '' GROUP BY user COLLATE NOCASE ORDER BY user COLLATE NOCASE;";
var users = new List<DatabaseUserOption>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
users.Add(new DatabaseUserOption
{
user = ReadString(reader, 0),
records = reader.IsDBNull(1) ? 0 : reader.GetInt64(1)
});
}
return users;
}
public static async Task<RecordsPage> GetRecordsAsync(string database, string query, int page, int pageSize, string user = null)
{
DatabaseSpec spec = GetSpec(database);
page = Math.Max(1, page);
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 25;
query = (query ?? string.Empty).Trim();
if (query.Length > 256)
throw new DatabaseEditorValidationException("search_too_long");
user = (user ?? string.Empty).Trim();
if (user.Length > MaxKeyLength)
throw new DatabaseEditorValidationException("key_too_long");
await using var connection = await OpenAsync(spec);
string where = BuildWhere(spec, query, user, out string searchValue, out string selectedUser);
long total;
await using (var count = connection.CreateCommand())
{
count.CommandText = $"SELECT COUNT(*) FROM {spec.table}{where};";
AddFilters(count, searchValue, selectedUser);
total = Convert.ToInt64(await count.ExecuteScalarAsync());
}
int pages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize));
page = Math.Min(page, pages);
int offset = (page - 1) * pageSize;
var records = new List<DatabaseRecord>(pageSize);
await using (var command = connection.CreateCommand())
{
command.CommandText = spec.timecode
? $"SELECT Id, user, card, item, length({TimeCodeRoad}), {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;"
: $"SELECT Id, user, length(data), substr(data, 1, 420), {spec.updatedText(spec.updatedColumn)} FROM {spec.table}{where} ORDER BY {spec.updatedColumn} DESC, Id DESC LIMIT @limit OFFSET @offset;";
AddFilters(command, searchValue, selectedUser);
command.Parameters.AddWithValue("@limit", pageSize);
command.Parameters.AddWithValue("@offset", offset);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
int index = 0;
var record = new DatabaseRecord
{
id = reader.GetInt64(index++),
user = ReadString(reader, index++)
};
if (spec.timecode)
{
record.card = ReadString(reader, index++);
record.item = ReadString(reader, index++);
}
record.dataLength = reader.IsDBNull(index) ? 0 : reader.GetInt64(index);
index++;
string dataValue = ReadString(reader, index++);
record.preview = BuildPreview(dataValue);
record.updated = ReadString(reader, index);
if (spec.timecode)
ReadPlaybackData(record, dataValue);
records.Add(record);
}
}
if (spec.timecode && records.Count > 0)
await EnrichTimeCodeRecordsAsync(records);
return new RecordsPage
{
database = spec.key,
page = page,
pageSize = pageSize,
total = total,
pages = pages,
records = records
};
}
public static async Task<DatabaseRecord> GetRecordAsync(string database, long id)
{
DatabaseSpec spec = GetSpec(database);
if (id <= 0)
throw new DatabaseEditorValidationException("invalid_id");
await using var connection = await OpenAsync(spec);
await using var command = connection.CreateCommand();
command.CommandText = spec.timecode
? $"SELECT Id, user, card, item, {TimeCodeRoad}, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;"
: $"SELECT Id, user, data, {spec.updatedText(spec.updatedColumn)} FROM {spec.table} WHERE Id = @id LIMIT 1;";
command.Parameters.AddWithValue("@id", id);
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow);
if (!await reader.ReadAsync())
return null;
int index = 0;
var record = new DatabaseRecord
{
id = reader.GetInt64(index++),
user = ReadString(reader, index++)
};
if (spec.timecode)
{
record.card = ReadString(reader, index++);
record.item = ReadString(reader, index++);
}
record.data = ReadString(reader, index++);
record.dataLength = Encoding.UTF8.GetByteCount(record.data ?? string.Empty);
record.updated = ReadString(reader, index);
return record;
}
public static async Task<SyncUserDetails> GetSyncUserAsync(long id)
{
if (id <= 0)
throw new DatabaseEditorValidationException("invalid_id");
await using var connection = await OpenAsync(Sync);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT Id, user, data, updated FROM bookmarks WHERE Id = @id LIMIT 1;";
command.Parameters.AddWithValue("@id", id);
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow);
if (!await reader.ReadAsync())
return null;
string data = ReadString(reader, 2);
var details = BuildSyncUserDetails(reader.GetInt64(0), ReadString(reader, 1), ReadString(reader, 3), data);
return details;
}
public static async Task<List<string>> SaveSyncItemAsync(SaveSyncItemRequest request)
{
if (request == null || request.recordId <= 0)
throw new DatabaseEditorValidationException("invalid_id");
string cardId = ValidateKey(request.cardId, "card_required");
var selected = new HashSet<string>(request.categories ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
foreach (string category in selected)
{
if (Array.IndexOf(SyncEditorCategories, category) < 0)
throw new DatabaseEditorValidationException("unknown_category");
}
int selectedStatuses = 0;
foreach (string category in SyncStatusCategories)
{
if (selected.Contains(category))
selectedStatuses++;
}
if (selectedStatuses > 1)
throw new DatabaseEditorValidationException("multiple_statuses");
await UpdateSyncJsonAsync(request.recordId, root =>
{
JsonObject card = FindSyncCard(root, cardId);
if (card == null)
throw new DatabaseEditorValidationException("card_not_found");
foreach (string category in SyncCategories)
{
JsonArray array = EnsureArray(root, category);
bool isSelected = selected.Contains(category);
bool isPresent = ContainsCardId(array, cardId);
if (isSelected && !isPresent)
array.Insert(0, CreateCardIdNode(cardId));
else if (!isSelected && isPresent)
RemoveCardId(array, cardId);
}
});
Serilog.Log.Information("DatabaseEditor updated Sync card {CardId} in record {RecordId}", cardId, request.recordId);
var result = new List<string>();
foreach (string category in SyncEditorCategories)
{
if (selected.Contains(category))
result.Add(category);
}
return result;
}
public static async Task<bool> DeleteSyncItemAsync(DeleteSyncItemRequest request)
{
if (request == null || request.recordId <= 0)
throw new DatabaseEditorValidationException("invalid_id");
string cardId = ValidateKey(request.cardId, "card_required");
bool removed = false;
await UpdateSyncJsonAsync(request.recordId, root =>
{
if (root["card"] is JsonArray cards)
{
for (int index = cards.Count - 1; index >= 0; index--)
{
if (cards[index] is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal))
{
cards.RemoveAt(index);
removed = true;
}
}
}
foreach (string category in SyncCategories)
{
if (root[category] is JsonArray array)
removed |= RemoveCardId(array, cardId);
}
});
if (removed)
Serilog.Log.Information("DatabaseEditor deleted Sync card {CardId} from record {RecordId}", cardId, request.recordId);
return removed;
}
public static async Task<DatabaseRecord> SaveAsync(SaveRecordRequest request)
{
if (request == null)
throw new DatabaseEditorValidationException("request_required");
DatabaseSpec spec = GetSpec(request.database);
string user = ValidateKey(request.user, "user_required");
string card = spec.timecode ? ValidateKey(request.card, "card_required") : null;
string item = spec.timecode ? ValidateKey(request.item, "item_required") : null;
string data = NormalizeJson(request.data);
long id = request.id.GetValueOrDefault();
if (id < 0)
throw new DatabaseEditorValidationException("invalid_id");
var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20));
bool acquired = await semaphore.WaitAsync();
if (!acquired)
throw new DatabaseEditorBusyException("database_busy");
try
{
await using var connection = await OpenAsync(spec);
using var transaction = connection.BeginTransaction();
string updated = DateTime.UtcNow.ToString("O");
// Правка из админки обязана поднять курсор выше всех строк пользователя, иначе
// клиенты её не увидят: дельты они спрашивают по `updated_at > since`.
string stamp = $"MAX(strftime('%s', 'now') * 1000, (SELECT COALESCE(MAX(updated_at), 0) + 1 FROM {spec.table} WHERE user = @user))";
await using var command = connection.CreateCommand();
command.Transaction = transaction;
if (id == 0)
{
command.CommandText = spec.timecode
? $"INSERT INTO {spec.table} (user, card, item, {TimeCodeColumns}, updated_at) VALUES (@user, @card, @item, {TimeCodeValues}, {stamp}); SELECT last_insert_rowid();"
: $"INSERT INTO {spec.table} (user, data, updated) VALUES (@user, @data, @updated); SELECT last_insert_rowid();";
}
else
{
command.CommandText = spec.timecode
? $"UPDATE {spec.table} SET user = @user, card = @card, item = @item, {TimeCodeAssignments}, updated_at = {stamp} WHERE Id = @id;"
: $"UPDATE {spec.table} SET user = @user, data = @data, updated = @updated WHERE Id = @id;";
command.Parameters.AddWithValue("@id", id);
}
command.Parameters.AddWithValue("@user", user);
if (spec.timecode)
{
command.Parameters.AddWithValue("@card", card);
command.Parameters.AddWithValue("@item", item);
}
command.Parameters.AddWithValue("@data", data);
if (!spec.timecode)
command.Parameters.AddWithValue("@updated", updated);
if (id == 0)
id = Convert.ToInt64(await command.ExecuteScalarAsync());
else if (await command.ExecuteNonQueryAsync() == 0)
throw new DatabaseEditorValidationException("record_not_found");
transaction.Commit();
Serilog.Log.Information("DatabaseEditor saved {Database} record {RecordId}", spec.key, id);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 19)
{
throw new DatabaseEditorConflictException("duplicate_record_key");
}
finally
{
semaphore.Release();
}
return await GetRecordAsync(spec.key, id);
}
public static async Task<bool> DeleteAsync(string database, long id)
{
DatabaseSpec spec = GetSpec(database);
if (id <= 0)
throw new DatabaseEditorValidationException("invalid_id");
var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20));
bool acquired = await semaphore.WaitAsync();
if (!acquired)
throw new DatabaseEditorBusyException("database_busy");
try
{
await using var connection = await OpenAsync(spec);
await using var command = connection.CreateCommand();
command.CommandText = $"DELETE FROM {spec.table} WHERE Id = @id;";
command.Parameters.AddWithValue("@id", id);
bool deleted = await command.ExecuteNonQueryAsync() > 0;
if (deleted)
Serilog.Log.Information("DatabaseEditor deleted {Database} record {RecordId}", spec.key, id);
return deleted;
}
finally
{
semaphore.Release();
}
}
public static async Task<RenameUserResult> RenameUserAsync(RenameUserRequest request)
{
if (request == null)
throw new DatabaseEditorValidationException("request_required");
string oldUser = ValidateKey(request.oldUser, "old_user_required");
string newUser = ValidateKey(request.newUser, "new_user_required");
if (string.Equals(oldUser, newUser, StringComparison.Ordinal))
throw new DatabaseEditorValidationException("user_name_unchanged");
if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path))
throw new DatabaseEditorValidationException("database_not_found");
var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20));
var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20));
bool syncAcquired = await syncSemaphore.WaitAsync();
if (!syncAcquired)
throw new DatabaseEditorBusyException("database_busy");
bool timecodeAcquired = false;
try
{
timecodeAcquired = await timecodeSemaphore.WaitAsync();
if (!timecodeAcquired)
throw new DatabaseEditorBusyException("database_busy");
await using var connection = await OpenAsync(TimeCode, pooling: false);
await using (var attach = connection.CreateCommand())
{
attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;";
attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path));
await attach.ExecuteNonQueryAsync();
}
using var transaction = connection.BeginTransaction();
await EnsureRenameTargetAvailableAsync(connection, transaction, "main.timecodes", oldUser, newUser);
await EnsureRenameTargetAvailableAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser);
int timecodeRecords = await RenameUserRowsAsync(connection, transaction, "main.timecodes", oldUser, newUser);
int syncRecords = await RenameUserRowsAsync(connection, transaction, "syncdb.bookmarks", oldUser, newUser);
if (timecodeRecords == 0 && syncRecords == 0)
throw new DatabaseEditorValidationException("user_not_found");
transaction.Commit();
Serilog.Log.Information("DatabaseEditor renamed user {OldUser} to {NewUser}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", oldUser, newUser, syncRecords, timecodeRecords);
return new RenameUserResult
{
oldUser = oldUser,
newUser = newUser,
syncRecords = syncRecords,
timecodeRecords = timecodeRecords
};
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 19)
{
throw new DatabaseEditorConflictException("rename_user_conflict");
}
finally
{
if (timecodeAcquired)
timecodeSemaphore.Release();
syncSemaphore.Release();
}
}
static async Task EnsureRenameTargetAvailableAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"SELECT 1 FROM {table} WHERE user = @newUser COLLATE NOCASE AND user <> @oldUser COLLATE NOCASE LIMIT 1;";
command.Parameters.AddWithValue("@oldUser", oldUser);
command.Parameters.AddWithValue("@newUser", newUser);
if (await command.ExecuteScalarAsync() != null)
throw new DatabaseEditorConflictException("rename_user_conflict");
}
static async Task<int> RenameUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string oldUser, string newUser)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"UPDATE {table} SET user = @newUser WHERE user = @oldUser COLLATE NOCASE;";
command.Parameters.AddWithValue("@oldUser", oldUser);
command.Parameters.AddWithValue("@newUser", newUser);
return await command.ExecuteNonQueryAsync();
}
public static async Task<DeleteUserResult> DeleteUserAsync(DeleteUserRequest request)
{
if (request == null)
throw new DatabaseEditorValidationException("request_required");
string user = ValidateKey(request.user, "user_required");
if (!File.Exists(Sync.path) || !File.Exists(TimeCode.path))
throw new DatabaseEditorValidationException("database_not_found");
var syncSemaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20));
var timecodeSemaphore = new SemaphorManager(TimeCode.semaphore, TimeSpan.FromSeconds(20));
bool syncAcquired = await syncSemaphore.WaitAsync();
if (!syncAcquired)
throw new DatabaseEditorBusyException("database_busy");
bool timecodeAcquired = false;
try
{
timecodeAcquired = await timecodeSemaphore.WaitAsync();
if (!timecodeAcquired)
throw new DatabaseEditorBusyException("database_busy");
await using var connection = await OpenAsync(TimeCode, pooling: false);
await using (var attach = connection.CreateCommand())
{
attach.CommandText = "ATTACH DATABASE @syncPath AS syncdb;";
attach.Parameters.AddWithValue("@syncPath", Path.GetFullPath(Sync.path));
await attach.ExecuteNonQueryAsync();
}
using var transaction = connection.BeginTransaction();
int timecodeRecords = await DeleteUserRowsAsync(connection, transaction, "main.timecodes", user);
int syncRecords = await DeleteUserRowsAsync(connection, transaction, "syncdb.bookmarks", user);
if (timecodeRecords == 0 && syncRecords == 0)
throw new DatabaseEditorValidationException("user_not_found");
transaction.Commit();
Serilog.Log.Information("DatabaseEditor deleted user {User}: {SyncRecords} Sync, {TimecodeRecords} TimeCode records", user, syncRecords, timecodeRecords);
return new DeleteUserResult { user = user, syncRecords = syncRecords, timecodeRecords = timecodeRecords };
}
finally
{
if (timecodeAcquired)
timecodeSemaphore.Release();
syncSemaphore.Release();
}
}
static async Task<int> DeleteUserRowsAsync(SqliteConnection connection, SqliteTransaction transaction, string table, string user)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"DELETE FROM {table} WHERE user = @user COLLATE NOCASE;";
command.Parameters.AddWithValue("@user", user);
return await command.ExecuteNonQueryAsync();
}
public static async Task<string> BackupAsync(string database)
{
DatabaseSpec spec = GetSpec(database);
var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20));
bool acquired = await semaphore.WaitAsync();
if (!acquired)
throw new DatabaseEditorBusyException("database_busy");
try
{
string directory = Path.Combine("database", "backup", "database-editor");
Directory.CreateDirectory(directory);
string fileName = $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql";
string destinationPath = Path.Combine(directory, fileName);
if (File.Exists(destinationPath))
destinationPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(spec.path)}-{DateTime.Now:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.sql");
await using var source = await OpenAsync(spec);
var destinationBuilder = new SqliteConnectionStringBuilder
{
DataSource = destinationPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
await using var destination = new SqliteConnection(destinationBuilder.ToString());
await destination.OpenAsync();
source.BackupDatabase(destination);
Serilog.Log.Information("DatabaseEditor backed up {Database} to {BackupPath}", spec.key, destinationPath);
return destinationPath.Replace('\\', '/');
}
finally
{
semaphore.Release();
}
}
public static async Task<List<DatabaseBackupResult>> BackupAllAsync()
{
var result = new List<DatabaseBackupResult>(2)
{
new() { database = TimeCode.key, path = await BackupAsync(TimeCode.key) },
new() { database = Sync.key, path = await BackupAsync(Sync.key) }
};
return result;
}
public static List<DatabaseBackupFile> GetBackups(string database)
{
DatabaseSpec spec = GetSpec(database);
string directory = Path.Combine("database", "backup", "database-editor");
var result = new List<DatabaseBackupFile>();
if (!Directory.Exists(directory))
return result;
string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-";
foreach (string path in Directory.GetFiles(directory, prefix + "*.sql"))
{
var file = new FileInfo(path);
result.Add(new DatabaseBackupFile
{
database = spec.key,
file = file.Name,
path = path.Replace('\\', '/'),
bytes = file.Length,
created = file.LastWriteTimeUtc.ToString("O")
});
}
result.Sort((left, right) => string.CompareOrdinal(right.created, left.created));
return result;
}
public static async Task<DatabaseRestoreResult> RestoreAsync(RestoreBackupRequest request)
{
if (request == null)
throw new DatabaseEditorValidationException("request_required");
DatabaseSpec spec = GetSpec(request.database);
string fileName = Path.GetFileName((request.file ?? string.Empty).Trim());
if (string.IsNullOrEmpty(fileName) || !string.Equals(fileName, request.file?.Trim(), StringComparison.Ordinal))
throw new DatabaseEditorValidationException("invalid_backup_file");
string prefix = Path.GetFileNameWithoutExtension(spec.path) + "-";
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !fileName.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
throw new DatabaseEditorValidationException("invalid_backup_file");
string directory = Path.GetFullPath(Path.Combine("database", "backup", "database-editor"));
string sourcePath = Path.GetFullPath(Path.Combine(directory, fileName));
if (!sourcePath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(sourcePath))
throw new DatabaseEditorValidationException("backup_not_found");
var semaphore = new SemaphorManager(spec.semaphore, TimeSpan.FromSeconds(20));
bool acquired = await semaphore.WaitAsync();
if (!acquired)
throw new DatabaseEditorBusyException("database_busy");
try
{
await ValidateBackupAsync(spec, sourcePath);
string safetyPath = await CreateBackupLockedAsync(spec, "before-restore");
var sourceBuilder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false };
await using (var source = new SqliteConnection(sourceBuilder.ToString()))
{
await source.OpenAsync();
await using var destination = await OpenAsync(spec);
source.BackupDatabase(destination);
}
SqliteConnection.ClearAllPools();
Serilog.Log.Warning("DatabaseEditor restored {Database} from {BackupPath}; safety backup: {SafetyPath}", spec.key, sourcePath, safetyPath);
return new DatabaseRestoreResult
{
database = spec.key,
restoredFrom = sourcePath.Replace('\\', '/'),
safetyBackup = safetyPath
};
}
finally
{
semaphore.Release();
}
}
static async Task ValidateBackupAsync(DatabaseSpec spec, string sourcePath)
{
try
{
var builder = new SqliteConnectionStringBuilder { DataSource = sourcePath, Mode = SqliteOpenMode.ReadOnly, Pooling = false };
await using var connection = new SqliteConnection(builder.ToString());
await connection.OpenAsync();
await using var integrity = connection.CreateCommand();
integrity.CommandText = "PRAGMA quick_check;";
if (!string.Equals(Convert.ToString(await integrity.ExecuteScalarAsync()), "ok", StringComparison.OrdinalIgnoreCase))
throw new DatabaseEditorValidationException("backup_integrity_failed");
await using var schema = connection.CreateCommand();
schema.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @table LIMIT 1;";
schema.Parameters.AddWithValue("@table", spec.table);
if (await schema.ExecuteScalarAsync() == null)
throw new DatabaseEditorValidationException("backup_schema_mismatch");
}
catch (SqliteException)
{
throw new DatabaseEditorValidationException("invalid_backup_database");
}
}
static async Task<string> CreateBackupLockedAsync(DatabaseSpec spec, string marker)
{
string directory = Path.Combine("database", "backup", "database-editor");
Directory.CreateDirectory(directory);
string baseName = Path.GetFileNameWithoutExtension(spec.path);
string destinationPath = Path.Combine(directory, $"{baseName}-{marker}-{DateTime.Now:yyyyMMdd-HHmmssfff}.sql");
await using var source = await OpenAsync(spec);
var builder = new SqliteConnectionStringBuilder { DataSource = destinationPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false };
await using var destination = new SqliteConnection(builder.ToString());
await destination.OpenAsync();
source.BackupDatabase(destination);
return destinationPath.Replace('\\', '/');
}
static async Task EnrichTimeCodeRecordsAsync(List<DatabaseRecord> records)
{
if (!File.Exists(Sync.path))
return;
var users = new HashSet<string>(StringComparer.Ordinal);
foreach (DatabaseRecord record in records)
{
if (!string.IsNullOrEmpty(record.user))
users.Add(record.user);
if (TryParseTimeCodeCard(record.card, out _, out string mediaType))
record.mediaType = mediaType;
}
if (users.Count == 0)
return;
var metadata = new Dictionary<string, MediaMetadata>(StringComparer.Ordinal);
await using var connection = await OpenAsync(Sync);
await using var command = connection.CreateCommand();
var placeholders = new List<string>(users.Count);
int parameterIndex = 0;
foreach (string user in users)
{
string parameter = "@user" + parameterIndex++;
placeholders.Add(parameter);
command.Parameters.AddWithValue(parameter, user);
}
command.CommandText = $"SELECT user, data FROM bookmarks WHERE user IN ({string.Join(",", placeholders)});";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string user = ReadString(reader, 0);
JsonObject root = ParseRoot(ReadString(reader, 1));
if (root?["card"] is not JsonArray cards)
continue;
foreach (JsonNode node in cards)
{
if (node is not JsonObject card)
continue;
string cardId = NodeText(card["id"]);
if (string.IsNullOrEmpty(cardId))
continue;
metadata[MediaKey(user, cardId)] = ReadMediaMetadata(card);
}
}
foreach (DatabaseRecord record in records)
{
if (!TryParseTimeCodeCard(record.card, out string cardId, out string mediaType))
continue;
record.mediaType = mediaType;
if (!metadata.TryGetValue(MediaKey(record.user, cardId), out MediaMetadata media))
continue;
record.title = media.title;
record.poster = media.poster;
record.year = media.year;
if (string.IsNullOrEmpty(record.mediaType))
record.mediaType = media.mediaType;
if (string.Equals(record.mediaType, "tv", StringComparison.OrdinalIgnoreCase) &&
TryFindEpisode(media, record.item, out int season, out int episode))
{
record.season = season;
record.episode = episode;
}
}
}
static SyncUserDetails BuildSyncUserDetails(long id, string user, string updated, string data)
{
JsonObject root = ParseRoot(data);
var categoryMap = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
var categoryOrderMap = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
var allCategoryIds = new List<string>();
foreach (string category in SyncCategories)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
var positions = new Dictionary<string, int>(StringComparer.Ordinal);
if (root?[category] is JsonArray array)
{
foreach (JsonNode node in array)
{
string cardId = NodeText(node);
if (!string.IsNullOrEmpty(cardId) && ids.Add(cardId))
{
positions[cardId] = positions.Count;
allCategoryIds.Add(cardId);
}
}
}
categoryMap[category] = ids;
categoryOrderMap[category] = positions;
}
var items = new List<SyncUserItem>();
var seen = new HashSet<string>(StringComparer.Ordinal);
if (root?["card"] is JsonArray cards)
{
foreach (JsonNode node in cards)
{
if (node is not JsonObject card)
continue;
string cardId = NodeText(card["id"]);
if (string.IsNullOrEmpty(cardId) || !seen.Add(cardId))
continue;
MediaMetadata media = ReadMediaMetadata(card);
items.Add(new SyncUserItem
{
cardId = cardId,
title = media.title,
poster = media.poster,
year = media.year,
mediaType = media.mediaType,
categories = CategoriesFor(cardId, categoryMap),
order = items.Count,
categoryOrder = CategoryOrderFor(cardId, categoryOrderMap)
});
}
}
foreach (string cardId in allCategoryIds)
{
if (!seen.Add(cardId))
continue;
items.Add(new SyncUserItem
{
cardId = cardId,
title = "Карточка #" + cardId,
categories = CategoriesFor(cardId, categoryMap),
order = items.Count,
categoryOrder = CategoryOrderFor(cardId, categoryOrderMap)
});
}
return new SyncUserDetails
{
id = id,
user = user,
updated = updated,
total = items.Count,
items = items
};
}
static async Task UpdateSyncJsonAsync(long recordId, Action<JsonObject> update)
{
var semaphore = new SemaphorManager(Sync.semaphore, TimeSpan.FromSeconds(20));
bool acquired = await semaphore.WaitAsync();
if (!acquired)
throw new DatabaseEditorBusyException("database_busy");
try
{
await using var connection = await OpenAsync(Sync);
using var transaction = connection.BeginTransaction();
string data;
await using (var select = connection.CreateCommand())
{
select.Transaction = transaction;
select.CommandText = "SELECT data FROM bookmarks WHERE Id = @id LIMIT 1;";
select.Parameters.AddWithValue("@id", recordId);
data = Convert.ToString(await select.ExecuteScalarAsync());
}
if (string.IsNullOrEmpty(data))
throw new DatabaseEditorValidationException("record_not_found");
JsonObject root = ParseRoot(data) ?? throw new DatabaseEditorValidationException("invalid_json");
update(root);
string updatedData = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
if (Encoding.UTF8.GetByteCount(updatedData) > MaxDataBytes)
throw new DatabaseEditorValidationException("data_too_large");
await using var save = connection.CreateCommand();
save.Transaction = transaction;
save.CommandText = "UPDATE bookmarks SET data = @data, updated = @updated WHERE Id = @id;";
save.Parameters.AddWithValue("@data", updatedData);
save.Parameters.AddWithValue("@updated", DateTime.UtcNow.ToString("O"));
save.Parameters.AddWithValue("@id", recordId);
if (await save.ExecuteNonQueryAsync() == 0)
throw new DatabaseEditorValidationException("record_not_found");
transaction.Commit();
}
finally
{
semaphore.Release();
}
}
static MediaMetadata ReadMediaMetadata(JsonObject card)
{
string mediaType = NodeText(card?["media_type"]);
if (string.IsNullOrEmpty(mediaType))
mediaType = card?["name"] != null && card?["title"] == null ? "tv" : "movie";
string date = FirstNotEmpty(NodeText(card?["release_date"]), NodeText(card?["first_air_date"]));
return new MediaMetadata
{
title = FirstNotEmpty(NodeText(card?["title"]), NodeText(card?["name"]), NodeText(card?["original_title"]), NodeText(card?["original_name"])),
poster = FirstNotEmpty(NodeText(card?["img"]), NodeText(card?["poster_path"])),
year = !string.IsNullOrEmpty(date) && date.Length >= 4 ? date.Substring(0, 4) : date,
mediaType = mediaType,
hashTitles = DistinctValues(
NodeText(card?["original_name"]),
NodeText(card?["original_title"]),
NodeText(card?["name"]),
NodeText(card?["title"])),
seasons = ReadPositiveInt(card?["number_of_seasons"]),
episodes = ReadPositiveInt(card?["number_of_episodes"])
};
}
static bool TryFindEpisode(MediaMetadata media, string item, out int season, out int episode)
{
season = 0;
episode = 0;
if (media?.hashTitles == null || media.hashTitles.Count == 0 ||
!long.TryParse(item, NumberStyles.None, CultureInfo.InvariantCulture, out long target) ||
target < 0 || target > 2147483648L)
return false;
int maxSeasons = media.seasons > 0 ? Math.Min(media.seasons, 100) : 50;
int maxEpisodes = media.episodes > 0 ? Math.Min(media.episodes, 3000) : 500;
maxSeasons = Math.Max(maxSeasons, 10);
maxEpisodes = Math.Max(maxEpisodes, 100);
if (FindEpisode(media.hashTitles, target, maxSeasons, maxEpisodes, out season, out episode))
return true;
// Long-running daily shows and anime can exceed the compact first pass.
if (maxSeasons < 100 || maxEpisodes < 3000)
return FindEpisode(media.hashTitles, target, 100, 3000, out season, out episode);
return false;
}
static bool FindEpisode(List<string> titles, long target, int maxSeasons, int maxEpisodes, out int season, out int episode)
{
var titleHashes = new List<(int hash, int power)>(titles.Count);
foreach (string title in titles)
{
int power = 1;
for (int index = 0; index < title.Length; index++)
power = unchecked(power * 31);
titleHashes.Add((JsHash(title), power));
}
for (int seasonNumber = 0; seasonNumber <= maxSeasons; seasonNumber++)
{
string separator = seasonNumber > 10 ? ":" : string.Empty;
string seasonPrefix = seasonNumber.ToString(CultureInfo.InvariantCulture) + separator;
for (int episodeNumber = 0; episodeNumber <= maxEpisodes; episodeNumber++)
{
int prefixHash = JsHash(seasonPrefix + episodeNumber.ToString(CultureInfo.InvariantCulture));
foreach ((int titleHash, int power) in titleHashes)
{
int hash = unchecked(prefixHash * power + titleHash);
if (Math.Abs((long)hash) != target)
continue;
season = seasonNumber;
episode = episodeNumber;
return true;
}
}
}
season = 0;
episode = 0;
return false;
}
static int JsHash(string value)
{
int hash = 0;
foreach (char character in value ?? string.Empty)
hash = unchecked(hash * 31 + character);
return hash;
}
static List<string> DistinctValues(params string[] values)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string value in values)
{
if (!string.IsNullOrWhiteSpace(value) && seen.Add(value))
result.Add(value);
}
return result;
}
static int ReadPositiveInt(JsonNode node)
{
return int.TryParse(NodeText(node), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
static List<string> CategoriesFor(string cardId, Dictionary<string, HashSet<string>> categoryMap)
{
var result = new List<string>();
foreach (string category in SyncEditorCategories)
{
if (categoryMap[category].Contains(cardId))
result.Add(category);
}
return result;
}
static Dictionary<string, int> CategoryOrderFor(string cardId, Dictionary<string, Dictionary<string, int>> categoryOrderMap)
{
var result = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (string category in SyncEditorCategories)
{
if (categoryOrderMap[category].TryGetValue(cardId, out int position))
result[category] = position;
}
return result;
}
static JsonObject FindSyncCard(JsonObject root, string cardId)
{
if (root?["card"] is not JsonArray cards)
return null;
foreach (JsonNode node in cards)
{
if (node is JsonObject card && string.Equals(NodeText(card["id"]), cardId, StringComparison.Ordinal))
return card;
}
return null;
}
static JsonArray EnsureArray(JsonObject root, string name)
{
if (root[name] is JsonArray array)
return array;
array = new JsonArray();
root[name] = array;
return array;
}
static bool RemoveCardId(JsonArray array, string cardId)
{
bool removed = false;
for (int index = array.Count - 1; index >= 0; index--)
{
if (string.Equals(NodeText(array[index]), cardId, StringComparison.Ordinal))
{
array.RemoveAt(index);
removed = true;
}
}
return removed;
}
static bool ContainsCardId(JsonArray array, string cardId)
{
foreach (JsonNode node in array)
{
if (string.Equals(NodeText(node), cardId, StringComparison.Ordinal))
return true;
}
return false;
}
static JsonNode CreateCardIdNode(string cardId)
{
if (long.TryParse(cardId, out long numericId))
return JsonValue.Create(numericId);
return JsonValue.Create(cardId);
}
static JsonObject ParseRoot(string data)
{
if (string.IsNullOrWhiteSpace(data))
return null;
try
{
return JsonNode.Parse(data) as JsonObject;
}
catch (JsonException)
{
return null;
}
}
static string NodeText(JsonNode node)
{
if (node == null)
return null;
if (node is JsonValue value && value.TryGetValue<string>(out string text))
return text;
return node.ToJsonString().Trim('"');
}
static string FirstNotEmpty(params string[] values)
{
foreach (string value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
static string MediaKey(string user, string cardId) => (user ?? string.Empty) + "\u001f" + cardId;
static bool TryParseTimeCodeCard(string value, out string cardId, out string mediaType)
{
cardId = null;
mediaType = null;
if (string.IsNullOrWhiteSpace(value))
return false;
int separator = value.LastIndexOf('_');
if (separator <= 0 || separator == value.Length - 1)
return false;
string suffix = value.Substring(separator + 1);
if (!string.Equals(suffix, "movie", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(suffix, "tv", StringComparison.OrdinalIgnoreCase))
return false;
cardId = value.Substring(0, separator);
mediaType = suffix.ToLowerInvariant();
return true;
}
static void ReadPlaybackData(DatabaseRecord record, string data)
{
if (string.IsNullOrWhiteSpace(data))
return;
try
{
using var document = JsonDocument.Parse(data);
JsonElement root = document.RootElement;
record.position = ReadDouble(root, "time");
record.duration = ReadDouble(root, "duration");
record.percent = ReadDouble(root, "percent");
}
catch (JsonException) { }
}
static double? ReadDouble(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out JsonElement value))
return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number))
return number;
if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), out number))
return number;
return null;
}
static async Task<DatabaseSummary> ReadSummaryAsync(DatabaseSpec spec)
{
var summary = new DatabaseSummary
{
database = spec.key,
title = spec.title,
file = spec.path.Replace('\\', '/'),
available = File.Exists(spec.path),
bytes = File.Exists(spec.path) ? new FileInfo(spec.path).Length : 0
};
if (!summary.available)
return summary;
await using var connection = await OpenAsync(spec);
await using var command = connection.CreateCommand();
command.CommandText = $"SELECT COUNT(*), {spec.updatedText($"MAX({spec.updatedColumn})")} FROM {spec.table};";
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow);
if (await reader.ReadAsync())
{
summary.records = reader.GetInt64(0);
summary.updated = ReadString(reader, 1);
}
return summary;
}
static DatabaseSpec GetSpec(string database)
{
if (string.Equals(database, Sync.key, StringComparison.OrdinalIgnoreCase))
return Sync;
if (string.Equals(database, TimeCode.key, StringComparison.OrdinalIgnoreCase))
return TimeCode;
throw new DatabaseEditorValidationException("unknown_database");
}
static async Task<SqliteConnection> OpenAsync(DatabaseSpec spec, bool pooling = true)
{
if (!File.Exists(spec.path))
throw new DatabaseEditorValidationException("database_not_found");
var builder = new SqliteConnectionStringBuilder
{
DataSource = spec.path,
Mode = SqliteOpenMode.ReadWrite,
Cache = SqliteCacheMode.Shared,
DefaultTimeout = 10,
Pooling = pooling
};
var connection = new SqliteConnection(builder.ToString());
await connection.OpenAsync();
return connection;
}
static string BuildWhere(DatabaseSpec spec, string query, string user, out string searchValue, out string selectedUser)
{
var clauses = new List<string>();
if (string.IsNullOrEmpty(query))
{
searchValue = null;
}
else
{
searchValue = "%" + query.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_") + "%";
clauses.Add(spec.timecode
? "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR identity LIKE @search ESCAPE '\\' COLLATE NOCASE OR card LIKE @search ESCAPE '\\' COLLATE NOCASE OR item LIKE @search ESCAPE '\\' COLLATE NOCASE)"
: "(CAST(Id AS TEXT) LIKE @search ESCAPE '\\' OR user LIKE @search ESCAPE '\\' COLLATE NOCASE OR data LIKE @search ESCAPE '\\' COLLATE NOCASE)");
}
selectedUser = string.IsNullOrEmpty(user) ? null : user;
if (selectedUser != null)
clauses.Add("user = @selectedUser COLLATE NOCASE");
return clauses.Count == 0 ? string.Empty : " WHERE " + string.Join(" AND ", clauses);
}
static void AddFilters(SqliteCommand command, string searchValue, string selectedUser)
{
if (searchValue != null)
command.Parameters.AddWithValue("@search", searchValue);
if (selectedUser != null)
command.Parameters.AddWithValue("@selectedUser", selectedUser);
}
static string ValidateKey(string value, string error)
{
value = (value ?? string.Empty).Trim();
if (value.Length == 0)
throw new DatabaseEditorValidationException(error);
if (value.Length > MaxKeyLength)
throw new DatabaseEditorValidationException("key_too_long");
return value;
}
static string NormalizeJson(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DatabaseEditorValidationException("data_required");
if (Encoding.UTF8.GetByteCount(value) > MaxDataBytes)
throw new DatabaseEditorValidationException("data_too_large");
try
{
using var document = JsonDocument.Parse(value);
if (document.RootElement.ValueKind != JsonValueKind.Object)
throw new DatabaseEditorValidationException("data_must_be_json_object");
return JsonSerializer.Serialize(document.RootElement);
}
catch (JsonException)
{
throw new DatabaseEditorValidationException("invalid_json");
}
}
static string BuildPreview(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
string preview = value.Replace("\r", " ").Replace("\n", " ").Replace("\t", " ");
while (preview.Contains(" ", StringComparison.Ordinal))
preview = preview.Replace(" ", " ", StringComparison.Ordinal);
return preview.Length <= 240 ? preview : preview.Substring(0, 240) + "…";
}
static string ReadString(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : reader.GetString(index);
}