mirror of
https://github.com/AntonyCorbett/JWLMerge
synced 2026-01-16 23:04:47 -05:00
Add project files.
This commit is contained in:
361
JWLMerge.BackupFileServices/BackupFileService.cs
Normal file
361
JWLMerge.BackupFileServices/BackupFileService.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
namespace JWLMerge.BackupFileServices
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Events;
|
||||
using Exceptions;
|
||||
using Helpers;
|
||||
using Models;
|
||||
using Models.Database;
|
||||
using Models.ManifestFile;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
|
||||
public sealed class BackupFileService : IBackupFileService
|
||||
{
|
||||
private const int ManifestVersionSupported = 1;
|
||||
private const int DatabaseVersionSupported = 5;
|
||||
private const string ManifestEntryName = "manifest.json";
|
||||
private const string DatabaseEntryName = "userData.db";
|
||||
|
||||
private readonly Merger _merger = new Merger();
|
||||
|
||||
public event EventHandler<ProgressEventArgs> ProgressEvent;
|
||||
|
||||
public BackupFileService()
|
||||
{
|
||||
_merger.ProgressEvent += MergerProgressEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BackupFile Load(string backupFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupFilePath))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(backupFilePath));
|
||||
}
|
||||
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
throw new BackupFileServicesException($"File does not exist: {backupFilePath}");
|
||||
}
|
||||
|
||||
ProgressMessage($"Loading {backupFilePath}");
|
||||
|
||||
using (var archive = new ZipArchive(File.OpenRead(backupFilePath), ZipArchiveMode.Read))
|
||||
{
|
||||
var manifest = ReadManifest(archive);
|
||||
|
||||
var database = ReadDatabase(archive, manifest.UserDataBackup.DatabaseName);
|
||||
|
||||
return new BackupFile
|
||||
{
|
||||
Manifest = manifest,
|
||||
Database = database
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BackupFile CreateBlank()
|
||||
{
|
||||
ProgressMessage("Creating blank file...");
|
||||
return new BackupFile();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteNewDatabase(
|
||||
BackupFile backup,
|
||||
string newDatabaseFilePath,
|
||||
string originalJwlibraryFilePathForSchema)
|
||||
{
|
||||
ProgressMessage("Writing merged database file...");
|
||||
|
||||
using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
Log.Logger.Debug("Created ZipArchive");
|
||||
|
||||
var tmpDatabaseFileName = ExtractDatabaseToFile(originalJwlibraryFilePathForSchema);
|
||||
try
|
||||
{
|
||||
backup.Manifest.UserDataBackup.Hash = GenerateDatabaseHash(tmpDatabaseFileName);
|
||||
AddDatabaseEntryToArchive(archive, backup.Database, tmpDatabaseFileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Logger.Debug("Deleting {tmpDatabaseFileName}", tmpDatabaseFileName);
|
||||
File.Delete(tmpDatabaseFileName);
|
||||
}
|
||||
|
||||
var manifestEntry = archive.CreateEntry(ManifestEntryName);
|
||||
|
||||
using (var entryStream = manifestEntry.Open())
|
||||
{
|
||||
var streamWriter = new StreamWriter(entryStream);
|
||||
streamWriter.Write(JsonConvert.SerializeObject(backup.Manifest));
|
||||
}
|
||||
}
|
||||
|
||||
using (var fileStream = new FileStream(newDatabaseFilePath, FileMode.Create))
|
||||
{
|
||||
ProgressMessage("Finishing...");
|
||||
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
memoryStream.CopyTo(fileStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BackupFile Merge(IReadOnlyCollection<string> files)
|
||||
{
|
||||
ProgressMessage($"Merging backup {files.Count} files...");
|
||||
|
||||
int fileNumber = 1;
|
||||
var originals = new List<BackupFile>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
Log.Logger.Debug("Merging file {fileNumber} = {fileName}", fileNumber++, file);
|
||||
Log.Logger.Debug("============");
|
||||
|
||||
var backupFile = Load(file);
|
||||
Clean(backupFile);
|
||||
originals.Add(backupFile);
|
||||
}
|
||||
|
||||
// just pick the first manifest as the basis for the
|
||||
// manifest in the final merged file...
|
||||
var newManifest = UpdateManifest(originals.First().Manifest);
|
||||
|
||||
var mergedDatabase = MergeDatabases(originals);
|
||||
return new BackupFile { Manifest = newManifest, Database = mergedDatabase };
|
||||
}
|
||||
|
||||
private Manifest UpdateManifest(Manifest manifestToBaseOn)
|
||||
{
|
||||
Log.Logger.Debug("Updating manifest");
|
||||
|
||||
Manifest result = manifestToBaseOn.Clone();
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
string simpleDateString = $"{now.Year}-{now.Month:D2}-{now.Day:D2}";
|
||||
|
||||
result.Name = $"merged_{simpleDateString}";
|
||||
result.CreationDate = simpleDateString;
|
||||
result.UserDataBackup.DeviceName = "JWLMerge";
|
||||
result.UserDataBackup.DatabaseName = DatabaseEntryName;
|
||||
|
||||
Log.Logger.Debug("Updated manifest");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Database MergeDatabases(IEnumerable<BackupFile> jwlibraryFiles)
|
||||
{
|
||||
ProgressMessage("Merging databases...");
|
||||
return _merger.Merge(jwlibraryFiles.Select(x => x.Database));
|
||||
}
|
||||
|
||||
private void MergerProgressEvent(object sender, ProgressEventArgs e)
|
||||
{
|
||||
OnProgressEvent(e);
|
||||
}
|
||||
|
||||
private void Clean(BackupFile backupFile)
|
||||
{
|
||||
Log.Logger.Debug("Cleaning backup file {backupFile}", backupFile.Manifest.Name);
|
||||
|
||||
var cleaner = new Cleaner(backupFile);
|
||||
int rowsRemoved = cleaner.Clean();
|
||||
if (rowsRemoved > 0)
|
||||
{
|
||||
ProgressMessage($"Removed {rowsRemoved} inaccessible rows");
|
||||
}
|
||||
}
|
||||
|
||||
private Database ReadDatabase(ZipArchive archive, string databaseName)
|
||||
{
|
||||
ProgressMessage($"Reading database {databaseName}...");
|
||||
|
||||
var databaseEntry = archive.Entries.FirstOrDefault(x => x.Name.Equals(databaseName));
|
||||
if (databaseEntry == null)
|
||||
{
|
||||
throw new BackupFileServicesException("Could not find database entry in jwlibrary file");
|
||||
}
|
||||
|
||||
Database result;
|
||||
var tmpFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
Log.Logger.Debug("Extracting database to {tmpFile}", tmpFile);
|
||||
databaseEntry.ExtractToFile(tmpFile, overwrite: true);
|
||||
|
||||
DataAccessLayer dataAccessLayer = new DataAccessLayer(tmpFile);
|
||||
result = dataAccessLayer.ReadDatabase();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Logger.Debug("Deleting {tmpFile}", tmpFile);
|
||||
File.Delete(tmpFile);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string ExtractDatabaseToFile(string jwlibraryFile)
|
||||
{
|
||||
Log.Logger.Debug("Opening ZipArchive {jwlibraryFile}", jwlibraryFile);
|
||||
|
||||
using (var archive = new ZipArchive(File.OpenRead(jwlibraryFile), ZipArchiveMode.Read))
|
||||
{
|
||||
var manifest = ReadManifest(archive);
|
||||
|
||||
var databaseEntry = archive.Entries.FirstOrDefault(x => x.Name.Equals(manifest.UserDataBackup.DatabaseName));
|
||||
var tmpFile = Path.GetTempFileName();
|
||||
databaseEntry.ExtractToFile(tmpFile, overwrite: true);
|
||||
|
||||
Log.Logger.Information("Created temp file: {tmpDatabaseFileName}", tmpFile);
|
||||
return tmpFile;
|
||||
}
|
||||
}
|
||||
|
||||
private Manifest ReadManifest(ZipArchive archive)
|
||||
{
|
||||
ProgressMessage("Reading manifest...");
|
||||
|
||||
var manifestEntry = archive.Entries.FirstOrDefault(x => x.Name.Equals(ManifestEntryName));
|
||||
if (manifestEntry == null)
|
||||
{
|
||||
throw new BackupFileServicesException("Could not find manifest entry in jwlibrary file");
|
||||
}
|
||||
|
||||
using (StreamReader stream = new StreamReader(manifestEntry.Open()))
|
||||
{
|
||||
var fileContents = stream.ReadToEnd();
|
||||
|
||||
Log.Logger.Debug("Parsing manifest");
|
||||
dynamic data = JObject.Parse(fileContents);
|
||||
|
||||
int manifestVersion = data.version ?? 0;
|
||||
if (!SupportManifestVersion(manifestVersion))
|
||||
{
|
||||
throw new BackupFileServicesException($"Manifest version {manifestVersion} is not supported");
|
||||
}
|
||||
|
||||
int databaseVersion = data.userDataBackup.schemaVersion ?? 0;
|
||||
if (!SupportDatabaseVersion(databaseVersion))
|
||||
{
|
||||
throw new BackupFileServicesException($"Database version {databaseVersion} is not supported");
|
||||
}
|
||||
|
||||
var result = JsonConvert.DeserializeObject<Manifest>(fileContents);
|
||||
|
||||
var prettyJson = JsonConvert.SerializeObject(result, Formatting.Indented);
|
||||
Log.Logger.Debug("Parsed manifest {manifestJson}", prettyJson);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SupportDatabaseVersion(int version)
|
||||
{
|
||||
return version == DatabaseVersionSupported;
|
||||
}
|
||||
|
||||
private bool SupportManifestVersion(int version)
|
||||
{
|
||||
return version == ManifestVersionSupported;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the sha256 database hash that is required in the manifest.json file.
|
||||
/// </summary>
|
||||
/// <param name="databaseFilePath">
|
||||
/// The database file path.
|
||||
/// </param>
|
||||
/// <returns>The hash.</returns>
|
||||
private string GenerateDatabaseHash(string databaseFilePath)
|
||||
{
|
||||
ProgressMessage("Generating database hash...");
|
||||
|
||||
using (FileStream fs = new FileStream(databaseFilePath, FileMode.Open))
|
||||
{
|
||||
BufferedStream bs = new BufferedStream(fs);
|
||||
using (SHA256Managed sha1 = new SHA256Managed())
|
||||
{
|
||||
byte[] hash = sha1.ComputeHash(bs);
|
||||
StringBuilder sb = new StringBuilder(2 * hash.Length);
|
||||
foreach (byte b in hash)
|
||||
{
|
||||
sb.AppendFormat("{0:x2}", b);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddDatabaseEntryToArchive(
|
||||
ZipArchive archive,
|
||||
Database database,
|
||||
string originalDatabaseFilePathForSchema)
|
||||
{
|
||||
ProgressMessage("Adding database to archive...");
|
||||
|
||||
var tmpDatabaseFile = CreateTemporaryDatabaseFile(database, originalDatabaseFilePathForSchema);
|
||||
try
|
||||
{
|
||||
archive.CreateEntryFromFile(tmpDatabaseFile, DatabaseEntryName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tmpDatabaseFile);
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTemporaryDatabaseFile(
|
||||
Database backupDatabase,
|
||||
string originalDatabaseFilePathForSchema)
|
||||
{
|
||||
string tmpFile = Path.GetTempFileName();
|
||||
|
||||
Log.Logger.Debug("Creating temporary database file {tmpFile}", tmpFile);
|
||||
|
||||
{
|
||||
var dataAccessLayer = new DataAccessLayer(originalDatabaseFilePathForSchema);
|
||||
dataAccessLayer.CreateEmptyClone(tmpFile);
|
||||
}
|
||||
|
||||
{
|
||||
var dataAccessLayer = new DataAccessLayer(tmpFile);
|
||||
dataAccessLayer.PopulateTables(backupDatabase);
|
||||
}
|
||||
|
||||
return tmpFile;
|
||||
}
|
||||
|
||||
private void OnProgressEvent(ProgressEventArgs e)
|
||||
{
|
||||
ProgressEvent?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private void OnProgressEvent(string message)
|
||||
{
|
||||
OnProgressEvent(new ProgressEventArgs { Message = message });
|
||||
}
|
||||
|
||||
private void ProgressMessage(string logMessage)
|
||||
{
|
||||
Log.Logger.Information(logMessage);
|
||||
OnProgressEvent(logMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
JWLMerge.BackupFileServices/Events/ProgressEventArgs.cs
Normal file
9
JWLMerge.BackupFileServices/Events/ProgressEventArgs.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace JWLMerge.BackupFileServices.Events
|
||||
{
|
||||
using System;
|
||||
|
||||
public class ProgressEventArgs : EventArgs
|
||||
{
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace JWLMerge.BackupFileServices.Exceptions
|
||||
{
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
public class BackupFileServicesException : Exception
|
||||
{
|
||||
public BackupFileServicesException(string errorMessage)
|
||||
: base(errorMessage)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
141
JWLMerge.BackupFileServices/Helpers/Cleaner.cs
Normal file
141
JWLMerge.BackupFileServices/Helpers/Cleaner.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
namespace JWLMerge.BackupFileServices.Helpers
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Models;
|
||||
using Serilog;
|
||||
|
||||
/// <summary>
|
||||
/// Cleans jwlibrary files by removing redundant or anomalous database rows.
|
||||
/// </summary>
|
||||
internal class Cleaner
|
||||
{
|
||||
private readonly BackupFile _backupFile;
|
||||
|
||||
public Cleaner(BackupFile backupFile)
|
||||
{
|
||||
_backupFile = backupFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the data, removing unused rows.
|
||||
/// </summary>
|
||||
/// <returns>Number of rows removed.</returns>
|
||||
public int Clean()
|
||||
{
|
||||
return CleanBlockRanges() + CleanLocations();
|
||||
}
|
||||
|
||||
private HashSet<int> GetUserMarkIdsInUse()
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
|
||||
foreach (var userMark in _backupFile.Database.UserMarks)
|
||||
{
|
||||
result.Add(userMark.UserMarkId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private HashSet<int> GetLocationIdsInUse()
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
|
||||
foreach (var bookmark in _backupFile.Database.Bookmarks)
|
||||
{
|
||||
result.Add(bookmark.LocationId);
|
||||
result.Add(bookmark.PublicationLocationId);
|
||||
}
|
||||
|
||||
foreach (var note in _backupFile.Database.Notes)
|
||||
{
|
||||
if (note.LocationId != null)
|
||||
{
|
||||
result.Add(note.LocationId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var userMark in _backupFile.Database.UserMarks)
|
||||
{
|
||||
result.Add(userMark.LocationId);
|
||||
}
|
||||
|
||||
Log.Logger.Debug($"Found {result.Count} location Ids in use");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the locations.
|
||||
/// </summary>
|
||||
/// <returns>Number of location rows removed.</returns>
|
||||
private int CleanLocations()
|
||||
{
|
||||
int removed = 0;
|
||||
|
||||
var locations = _backupFile.Database.Locations;
|
||||
if (locations.Any())
|
||||
{
|
||||
var locationIds = GetLocationIdsInUse();
|
||||
|
||||
foreach (var location in Enumerable.Reverse(locations))
|
||||
{
|
||||
if (!locationIds.Contains(location.LocationId))
|
||||
{
|
||||
Log.Logger.Debug($"Removing redundant location id: {location.LocationId}");
|
||||
locations.Remove(location);
|
||||
++removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the block ranges.
|
||||
/// </summary>
|
||||
/// <returns>Number of ranges removed.</returns>
|
||||
private int CleanBlockRanges()
|
||||
{
|
||||
int removed = 0;
|
||||
|
||||
var userMarkIdsFound = new HashSet<int>();
|
||||
|
||||
var ranges = _backupFile.Database.BlockRanges;
|
||||
if (ranges.Any())
|
||||
{
|
||||
var userMarkIds = GetUserMarkIdsInUse();
|
||||
|
||||
foreach (var range in Enumerable.Reverse(ranges))
|
||||
{
|
||||
if (!userMarkIds.Contains(range.UserMarkId))
|
||||
{
|
||||
Log.Logger.Debug($"Removing redundant range: {range.BlockRangeId}");
|
||||
ranges.Remove(range);
|
||||
++removed;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (userMarkIdsFound.Contains(range.UserMarkId))
|
||||
{
|
||||
// don't know how to handle this situation - we are expecting
|
||||
// a unique constraint on the UserMarkId column but have found
|
||||
// occasional duplication!
|
||||
Log.Logger.Debug($"Removing redundant range (duplicate UserMarkId): {range.BlockRangeId}");
|
||||
ranges.Remove(range);
|
||||
++removed;
|
||||
}
|
||||
else
|
||||
{
|
||||
userMarkIdsFound.Add(range.UserMarkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
}
|
||||
348
JWLMerge.BackupFileServices/Helpers/DataAccessLayer.cs
Normal file
348
JWLMerge.BackupFileServices/Helpers/DataAccessLayer.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
namespace JWLMerge.BackupFileServices.Helpers
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SQLite;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Models.Database;
|
||||
using Serilog;
|
||||
|
||||
/// <summary>
|
||||
/// Isolates all data access to the SQLite database embedded in
|
||||
/// jwlibrary files.
|
||||
/// </summary>
|
||||
internal class DataAccessLayer
|
||||
{
|
||||
private readonly string _databaseFilePath;
|
||||
|
||||
public DataAccessLayer(string databaseFilePath)
|
||||
{
|
||||
_databaseFilePath = databaseFilePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new empty database using the schema from the current database.
|
||||
/// </summary>
|
||||
/// <param name="cloneFilePath">The clone file path (the new database).</param>
|
||||
public void CreateEmptyClone(string cloneFilePath)
|
||||
{
|
||||
Log.Logger.Debug($"Creating empty clone: {cloneFilePath}");
|
||||
|
||||
using (var source = CreateConnection(_databaseFilePath))
|
||||
using (var destination = CreateConnection(cloneFilePath))
|
||||
{
|
||||
source.BackupDatabase(destination, "main", "main", -1, null, -1);
|
||||
ClearData(destination);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the current database using the specified data.
|
||||
/// </summary>
|
||||
/// <param name="dataToUse">The data to use.</param>
|
||||
public void PopulateTables(Database dataToUse)
|
||||
{
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
PopulateTable(connection, dataToUse.Locations);
|
||||
PopulateTable(connection, dataToUse.Notes);
|
||||
PopulateTable(connection, dataToUse.UserMarks);
|
||||
PopulateTable(connection, dataToUse.Tags);
|
||||
PopulateTable(connection, dataToUse.TagMaps);
|
||||
PopulateTable(connection, dataToUse.BlockRanges);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current database.
|
||||
/// </summary>
|
||||
/// <returns><see cref="Database"/></returns>
|
||||
public Database ReadDatabase()
|
||||
{
|
||||
var result = new Database();
|
||||
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
result.LastModified = ReadAllRows(connection, ReadLastModified).FirstOrDefault();
|
||||
result.Locations = ReadAllRows(connection, ReadLocation);
|
||||
result.Notes = ReadAllRows(connection, ReadNote);
|
||||
result.Tags = ReadAllRows(connection, ReadTag);
|
||||
result.TagMaps = ReadAllRows(connection, ReadTagMap);
|
||||
result.BlockRanges = ReadAllRows(connection, ReadBlockRange);
|
||||
result.Bookmarks = ReadAllRows(connection, ReadBookmark);
|
||||
result.UserMarks = ReadAllRows(connection, ReadUserMark);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<TRowType> ReadAllRows<TRowType>(
|
||||
SQLiteConnection connection,
|
||||
Func<SQLiteDataReader, TRowType> readRowFunction)
|
||||
{
|
||||
using (SQLiteCommand cmd = connection.CreateCommand())
|
||||
{
|
||||
var result = new List<TRowType>();
|
||||
var tableName = typeof(TRowType).Name;
|
||||
|
||||
cmd.CommandText = $"select * from {tableName}";
|
||||
Log.Logger.Debug($"SQL: {cmd.CommandText}");
|
||||
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(readRowFunction(reader));
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger.Debug($"SQL resultset count: {result.Count}");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private string ReadString(SQLiteDataReader reader, string columnName)
|
||||
{
|
||||
return reader[columnName].ToString();
|
||||
}
|
||||
|
||||
private string ReadNullableString(SQLiteDataReader reader, string columnName)
|
||||
{
|
||||
var value = reader[columnName];
|
||||
return value == DBNull.Value ? null : value.ToString();
|
||||
}
|
||||
|
||||
private int ReadInt(SQLiteDataReader reader, string columnName)
|
||||
{
|
||||
return Convert.ToInt32(reader[columnName]);
|
||||
}
|
||||
|
||||
private int? ReadNullableInt(SQLiteDataReader reader, string columnName)
|
||||
{
|
||||
var value = reader[columnName];
|
||||
return value == DBNull.Value ? (int?)null : Convert.ToInt32(value);
|
||||
}
|
||||
|
||||
private Location ReadLocation(SQLiteDataReader reader)
|
||||
{
|
||||
return new Location
|
||||
{
|
||||
LocationId = ReadInt(reader, "LocationId"),
|
||||
BookNumber = ReadNullableInt(reader, "BookNumber"),
|
||||
ChapterNumber = ReadNullableInt(reader, "ChapterNumber"),
|
||||
DocumentId = ReadNullableInt(reader, "DocumentId"),
|
||||
Track = ReadNullableInt(reader, "Track"),
|
||||
IssueTagNumber = ReadInt(reader, "IssueTagNumber"),
|
||||
KeySymbol = ReadString(reader, "KeySymbol"),
|
||||
MepsLanguage = ReadInt(reader, "MepsLanguage"),
|
||||
Type = ReadInt(reader, "Type"),
|
||||
Title = ReadNullableString(reader, "Title")
|
||||
};
|
||||
}
|
||||
|
||||
private Note ReadNote(SQLiteDataReader reader)
|
||||
{
|
||||
return new Note
|
||||
{
|
||||
NoteId = ReadInt(reader, "NoteId"),
|
||||
Guid = ReadString(reader, "Guid"),
|
||||
UserMarkId = ReadNullableInt(reader, "UserMarkId"),
|
||||
LocationId = ReadNullableInt(reader, "LocationId"),
|
||||
Title = ReadNullableString(reader, "Title"),
|
||||
Content = ReadNullableString(reader, "Content"),
|
||||
LastModified = ReadString(reader, "LastModified"),
|
||||
BlockType = ReadInt(reader, "BlockType"),
|
||||
BlockIdentifier = ReadNullableInt(reader, "BlockIdentifier")
|
||||
};
|
||||
}
|
||||
|
||||
private Tag ReadTag(SQLiteDataReader reader)
|
||||
{
|
||||
return new Tag
|
||||
{
|
||||
TagId = ReadInt(reader, "TagId"),
|
||||
Type = ReadInt(reader, "Type"),
|
||||
Name = ReadString(reader, "Name")
|
||||
};
|
||||
}
|
||||
|
||||
private TagMap ReadTagMap(SQLiteDataReader reader)
|
||||
{
|
||||
return new TagMap
|
||||
{
|
||||
TagMapId = ReadInt(reader, "TagMapId"),
|
||||
Type = ReadInt(reader, "Type"),
|
||||
TypeId = ReadInt(reader, "TypeId"),
|
||||
TagId = ReadInt(reader, "TagId"),
|
||||
Position = ReadInt(reader, "Position")
|
||||
};
|
||||
}
|
||||
|
||||
private BlockRange ReadBlockRange(SQLiteDataReader reader)
|
||||
{
|
||||
return new BlockRange
|
||||
{
|
||||
BlockRangeId = ReadInt(reader, "BlockRangeId"),
|
||||
BlockType = ReadInt(reader, "BlockType"),
|
||||
Identifier = ReadInt(reader, "Identifier"),
|
||||
StartToken = ReadNullableInt(reader, "StartToken"),
|
||||
EndToken = ReadNullableInt(reader, "EndToken"),
|
||||
UserMarkId = ReadInt(reader, "UserMarkId")
|
||||
};
|
||||
}
|
||||
|
||||
private Bookmark ReadBookmark(SQLiteDataReader reader)
|
||||
{
|
||||
return new Bookmark
|
||||
{
|
||||
BookmarkId = ReadInt(reader, "BookmarkId"),
|
||||
LocationId = ReadInt(reader, "LocationId"),
|
||||
PublicationLocationId = ReadInt(reader, "PublicationLocationId"),
|
||||
Slot = ReadInt(reader, "Slot"),
|
||||
Title = ReadString(reader, "Title"),
|
||||
Snippet = ReadNullableString(reader, "Snippet"),
|
||||
BlockType = ReadInt(reader, "BlockType"),
|
||||
BlockIdentifier = ReadInt(reader, "BlockIdentifier")
|
||||
};
|
||||
}
|
||||
|
||||
private LastModified ReadLastModified(SQLiteDataReader reader)
|
||||
{
|
||||
return new LastModified
|
||||
{
|
||||
TimeLastModified = ReadString(reader, "LastModified")
|
||||
};
|
||||
}
|
||||
|
||||
private UserMark ReadUserMark(SQLiteDataReader reader)
|
||||
{
|
||||
return new UserMark
|
||||
{
|
||||
UserMarkId = ReadInt(reader, "UserMarkId"),
|
||||
ColorIndex = ReadInt(reader, "ColorIndex"),
|
||||
LocationId = ReadInt(reader, "LocationId"),
|
||||
StyleIndex = ReadInt(reader, "StyleIndex"),
|
||||
UserMarkGuid = ReadString(reader, "UserMarkGuid"),
|
||||
Version = ReadInt(reader, "Version")
|
||||
};
|
||||
}
|
||||
|
||||
private SQLiteConnection CreateConnection()
|
||||
{
|
||||
return CreateConnection(_databaseFilePath);
|
||||
}
|
||||
|
||||
private SQLiteConnection CreateConnection(string filePath)
|
||||
{
|
||||
var connectionString = $"Data Source={filePath};Version=3;";
|
||||
Log.Logger.Debug("SQL create connection: {connection}", connectionString);
|
||||
|
||||
var connection = new SQLiteConnection(connectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
private void ClearData(SQLiteConnection connection)
|
||||
{
|
||||
ClearTable(connection, "UserMark");
|
||||
ClearTable(connection, "TagMap");
|
||||
ClearTable(connection, "Tag");
|
||||
ClearTable(connection, "Note");
|
||||
ClearTable(connection, "Location");
|
||||
ClearTable(connection, "Bookmark");
|
||||
ClearTable(connection, "BlockRange");
|
||||
|
||||
UpdateLastModified(connection);
|
||||
|
||||
VacuumDatabase(connection);
|
||||
}
|
||||
|
||||
private void VacuumDatabase(SQLiteConnection connection)
|
||||
{
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "vacuum;";
|
||||
Log.Logger.Debug($"SQL: {command.CommandText}");
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLastModified(SQLiteConnection connection)
|
||||
{
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "delete from LastModified; insert into LastModified default values";
|
||||
Log.Logger.Debug($"SQL: {command.CommandText}");
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearTable(SQLiteConnection connection, string tableName)
|
||||
{
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = $"delete from {tableName}";
|
||||
Log.Logger.Debug($"SQL: {command.CommandText}");
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateTable<TRowType>(SQLiteConnection connection, List<TRowType> rows)
|
||||
{
|
||||
var tableName = typeof(TRowType).Name;
|
||||
var columnNames = GetColumnNames<TRowType>();
|
||||
var columnNamesCsv = string.Join(",", columnNames);
|
||||
var paramNames = GetParamNames(columnNames);
|
||||
var paramNamesCsv = string.Join(",", paramNames);
|
||||
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
using (SQLiteCommand cmd = connection.CreateCommand())
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine($"insert into {tableName} ({columnNamesCsv}) values ({paramNamesCsv})");
|
||||
|
||||
cmd.CommandText = sb.ToString();
|
||||
AddPopulateTableParams(cmd, columnNames, paramNames, row);
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddPopulateTableParams<TRowType>(
|
||||
SQLiteCommand cmd,
|
||||
List<string> columnNames,
|
||||
List<string> paramNames,
|
||||
TRowType row)
|
||||
{
|
||||
for (int n = 0; n < columnNames.Count; ++n)
|
||||
{
|
||||
var value = row.GetType().GetProperty(columnNames[n])?.GetValue(row);
|
||||
cmd.Parameters.AddWithValue(paramNames[n], value);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> GetParamNames(IReadOnlyCollection<string> columnNames)
|
||||
{
|
||||
return columnNames.Select(columnName => $"@{columnName}").ToList();
|
||||
}
|
||||
|
||||
private List<string> GetColumnNames<TRowType>()
|
||||
{
|
||||
PropertyInfo[] properties = typeof(TRowType).GetProperties();
|
||||
return properties.Select(property => property.Name).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
32
JWLMerge.BackupFileServices/Helpers/IdTranslator.cs
Normal file
32
JWLMerge.BackupFileServices/Helpers/IdTranslator.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace JWLMerge.BackupFileServices.Helpers
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Used by the <see cref="Merger"/> to map old and new id values./>
|
||||
/// </summary>
|
||||
internal class IdTranslator
|
||||
{
|
||||
private readonly Dictionary<int, int> _ids;
|
||||
|
||||
public IdTranslator()
|
||||
{
|
||||
_ids = new Dictionary<int, int>();
|
||||
}
|
||||
|
||||
public int GetTranslatedId(int oldId)
|
||||
{
|
||||
return _ids.TryGetValue(oldId, out var translatedId) ? translatedId : 0;
|
||||
}
|
||||
|
||||
public void Add(int oldId, int translatedId)
|
||||
{
|
||||
_ids[oldId] = translatedId;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_ids.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
277
JWLMerge.BackupFileServices/Helpers/Merger.cs
Normal file
277
JWLMerge.BackupFileServices/Helpers/Merger.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
namespace JWLMerge.BackupFileServices.Helpers
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Events;
|
||||
using Models.Database;
|
||||
using Serilog;
|
||||
|
||||
/// <summary>
|
||||
/// Merges the SQLite databases.
|
||||
/// </summary>
|
||||
internal sealed class Merger
|
||||
{
|
||||
private readonly IdTranslator _translatedLocationIds = new IdTranslator();
|
||||
private readonly IdTranslator _translatedTagIds = new IdTranslator();
|
||||
private readonly IdTranslator _translatedUserMarkIds = new IdTranslator();
|
||||
private readonly IdTranslator _translatedNoteIds = new IdTranslator();
|
||||
|
||||
private int _maxLocationId;
|
||||
private int _maxUserMarkId;
|
||||
private int _maxNoteId;
|
||||
private int _maxTagId;
|
||||
private int _maxTagMapId;
|
||||
private int _maxBlockRangeId;
|
||||
|
||||
public event EventHandler<ProgressEventArgs> ProgressEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Merges the specified databases.
|
||||
/// </summary>
|
||||
/// <param name="databasesToMerge">The databases to merge.</param>
|
||||
/// <returns><see cref="Database"/></returns>
|
||||
public Database Merge(IEnumerable<Database> databasesToMerge)
|
||||
{
|
||||
var result = new Database();
|
||||
result.InitBlank();
|
||||
|
||||
ClearMaxIds();
|
||||
|
||||
foreach (var database in databasesToMerge)
|
||||
{
|
||||
Merge(database, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ClearMaxIds()
|
||||
{
|
||||
_maxLocationId = 0;
|
||||
_maxUserMarkId = 0;
|
||||
_maxNoteId = 0;
|
||||
_maxTagId = 0;
|
||||
_maxTagMapId = 0;
|
||||
_maxBlockRangeId = 0;
|
||||
}
|
||||
|
||||
private void Merge(Database source, Database destination)
|
||||
{
|
||||
ClearTranslators();
|
||||
|
||||
MergeUserMarks(source, destination);
|
||||
MergeNotes(source, destination);
|
||||
MergeTags(source, destination);
|
||||
MergeTagMap(source, destination);
|
||||
MergeBlockRanges(source, destination);
|
||||
|
||||
destination.ReinitializeIndexes();
|
||||
}
|
||||
|
||||
private void ClearTranslators()
|
||||
{
|
||||
_translatedLocationIds.Clear();
|
||||
_translatedTagIds.Clear();
|
||||
_translatedUserMarkIds.Clear();
|
||||
_translatedNoteIds.Clear();
|
||||
}
|
||||
|
||||
private void MergeBlockRanges(Database source, Database destination)
|
||||
{
|
||||
ProgressMessage("Merging block ranges...");
|
||||
|
||||
foreach (var range in source.BlockRanges)
|
||||
{
|
||||
var userMarkId = _translatedUserMarkIds.GetTranslatedId(range.UserMarkId);
|
||||
var existingRange = destination.FindBlockRange(userMarkId);
|
||||
if (existingRange == null)
|
||||
{
|
||||
InsertBlockRange(range, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeTagMap(Database source, Database destination)
|
||||
{
|
||||
ProgressMessage("Merging tag map...");
|
||||
|
||||
foreach (var tagMap in source.TagMaps)
|
||||
{
|
||||
if (tagMap.Type == 1)
|
||||
{
|
||||
// a tag on a note...
|
||||
var tagId = _translatedTagIds.GetTranslatedId(tagMap.TagId);
|
||||
var noteId = _translatedNoteIds.GetTranslatedId(tagMap.TypeId);
|
||||
|
||||
var existingTagMap = destination.FindTagMap(tagId, noteId);
|
||||
if (existingTagMap == null)
|
||||
{
|
||||
InsertTagMap(tagMap, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeTags(Database source, Database destination)
|
||||
{
|
||||
ProgressMessage("Merging tags...");
|
||||
|
||||
foreach (var tag in source.Tags)
|
||||
{
|
||||
var existingTag = destination.FindTag(tag.Name);
|
||||
if (existingTag != null)
|
||||
{
|
||||
_translatedTagIds.Add(tag.TagId, existingTag.TagId);
|
||||
}
|
||||
else
|
||||
{
|
||||
InsertTag(tag, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeUserMarks(Database source, Database destination)
|
||||
{
|
||||
ProgressMessage("Merging user marks...");
|
||||
|
||||
foreach (var userMark in source.UserMarks)
|
||||
{
|
||||
var existingUserMark = destination.FindUserMark(userMark.UserMarkGuid);
|
||||
if (existingUserMark != null)
|
||||
{
|
||||
// user mark already exists in destination...
|
||||
_translatedUserMarkIds.Add(userMark.UserMarkId, existingUserMark.UserMarkId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var referencedLocation = userMark.LocationId;
|
||||
var location = source.FindLocation(referencedLocation);
|
||||
|
||||
InsertLocation(location, destination);
|
||||
InsertUserMark(userMark, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertLocation(Location location, Database destination)
|
||||
{
|
||||
if (_translatedLocationIds.GetTranslatedId(location.LocationId) == 0)
|
||||
{
|
||||
Location newLocation = location.Clone();
|
||||
newLocation.LocationId = ++_maxLocationId;
|
||||
destination.Locations.Add(newLocation);
|
||||
|
||||
_translatedLocationIds.Add(location.LocationId, newLocation.LocationId);
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertUserMark(UserMark userMark, Database destination)
|
||||
{
|
||||
UserMark newUserMark = userMark.Clone();
|
||||
newUserMark.UserMarkId = ++_maxUserMarkId;
|
||||
newUserMark.LocationId = _translatedLocationIds.GetTranslatedId(userMark.LocationId);
|
||||
destination.UserMarks.Add(newUserMark);
|
||||
|
||||
_translatedUserMarkIds.Add(userMark.UserMarkId, newUserMark.UserMarkId);
|
||||
}
|
||||
|
||||
private void InsertTag(Tag tag, Database destination)
|
||||
{
|
||||
Tag newTag = tag.Clone();
|
||||
newTag.TagId = ++_maxTagId;
|
||||
destination.Tags.Add(newTag);
|
||||
|
||||
_translatedTagIds.Add(tag.TagId, newTag.TagId);
|
||||
}
|
||||
|
||||
private void InsertTagMap(TagMap tagMap, Database destination)
|
||||
{
|
||||
TagMap newTagMap = tagMap.Clone();
|
||||
newTagMap.TagMapId = ++_maxTagMapId;
|
||||
newTagMap.TagId = _translatedTagIds.GetTranslatedId(tagMap.TagId);
|
||||
newTagMap.TypeId = _translatedNoteIds.GetTranslatedId(tagMap.TypeId);
|
||||
|
||||
destination.TagMaps.Add(newTagMap);
|
||||
}
|
||||
|
||||
private void InsertNote(Note note, Database destination)
|
||||
{
|
||||
Note newNote = note.Clone();
|
||||
newNote.NoteId = ++_maxNoteId;
|
||||
|
||||
if (note.UserMarkId != null)
|
||||
{
|
||||
newNote.UserMarkId = _translatedUserMarkIds.GetTranslatedId(note.UserMarkId.Value);
|
||||
}
|
||||
|
||||
if (note.LocationId != null)
|
||||
{
|
||||
newNote.LocationId = _translatedLocationIds.GetTranslatedId(note.LocationId.Value);
|
||||
}
|
||||
|
||||
destination.Notes.Add(newNote);
|
||||
_translatedNoteIds.Add(note.NoteId, newNote.NoteId);
|
||||
}
|
||||
|
||||
private void InsertBlockRange(BlockRange range, Database destination)
|
||||
{
|
||||
BlockRange newRange = range.Clone();
|
||||
newRange.BlockRangeId = ++_maxBlockRangeId;
|
||||
|
||||
newRange.UserMarkId = _translatedUserMarkIds.GetTranslatedId(range.UserMarkId);
|
||||
destination.BlockRanges.Add(newRange);
|
||||
}
|
||||
|
||||
private void MergeNotes(Database source, Database destination)
|
||||
{
|
||||
ProgressMessage("Merging notes...");
|
||||
|
||||
foreach (var note in source.Notes)
|
||||
{
|
||||
var existingNote = destination.FindNote(note.Guid);
|
||||
if (existingNote != null)
|
||||
{
|
||||
// note already exists in destination...
|
||||
if (existingNote.GetLastModifiedDateTime() < note.GetLastModifiedDateTime())
|
||||
{
|
||||
// ...but it's older
|
||||
UpdateNote(note, existingNote);
|
||||
}
|
||||
|
||||
_translatedNoteIds.Add(note.NoteId, existingNote.NoteId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// a new note...
|
||||
if (note.LocationId != null && _translatedLocationIds.GetTranslatedId(note.LocationId.Value) == 0)
|
||||
{
|
||||
var referencedLocation = note.LocationId.Value;
|
||||
var location = source.FindLocation(referencedLocation);
|
||||
|
||||
InsertLocation(location, destination);
|
||||
}
|
||||
|
||||
InsertNote(note, destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNote(Note source, Note destination)
|
||||
{
|
||||
destination.Title = source.Title;
|
||||
destination.Content = source.Content;
|
||||
destination.LastModified = source.LastModified;
|
||||
}
|
||||
|
||||
private void OnProgressEvent(string message)
|
||||
{
|
||||
ProgressEvent?.Invoke(this, new ProgressEventArgs { Message = message });
|
||||
}
|
||||
|
||||
private void ProgressMessage(string logMessage)
|
||||
{
|
||||
Log.Logger.Information(logMessage);
|
||||
OnProgressEvent(logMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
JWLMerge.BackupFileServices/IBackupFileService.cs
Normal file
52
JWLMerge.BackupFileServices/IBackupFileService.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace JWLMerge.BackupFileServices
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Events;
|
||||
using Models;
|
||||
|
||||
/// <summary>
|
||||
/// The BackupFileService interface.
|
||||
/// </summary>
|
||||
public interface IBackupFileService
|
||||
{
|
||||
event EventHandler<ProgressEventArgs> ProgressEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the specified backup file.
|
||||
/// </summary>
|
||||
/// <param name="backupFilePath">
|
||||
/// The backup file path.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The <see cref="BackupFile"/>.
|
||||
/// </returns>
|
||||
BackupFile Load(string backupFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Merges the specified backup files.
|
||||
/// </summary>
|
||||
/// <param name="files">The files.</param>
|
||||
/// <returns>Merged file</returns>
|
||||
BackupFile Merge(IReadOnlyCollection<string> files);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a blank backup file.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A <see cref="BackupFile"/>.
|
||||
/// </returns>
|
||||
BackupFile CreateBlank();
|
||||
|
||||
/// <summary>
|
||||
/// Writes the specified backup to a "jwlibrary" file.
|
||||
/// </summary>
|
||||
/// <param name="backup">The backup data.</param>
|
||||
/// <param name="newDatabaseFilePath">The new database file path.</param>
|
||||
/// <param name="originalJwlibraryFilePathForSchema">The original jwlibrary file path on which to base the new schema.</param>
|
||||
void WriteNewDatabase(
|
||||
BackupFile backup,
|
||||
string newDatabaseFilePath,
|
||||
string originalJwlibraryFilePathForSchema);
|
||||
}
|
||||
}
|
||||
100
JWLMerge.BackupFileServices/JWLMerge.BackupFileServices.csproj
Normal file
100
JWLMerge.BackupFileServices/JWLMerge.BackupFileServices.csproj
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{83446629-CDBB-43FF-B628-1B8A3A9603C3}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>JWLMerge.BackupFileServices</RootNamespace>
|
||||
<AssemblyName>JWLMerge.BackupFileServices</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.Build.Tasks.v4.0" />
|
||||
<Reference Include="Newtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Serilog, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.2.6.0\lib\net46\Serilog.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Serilog.Sinks.File, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.Sinks.File.3.2.0\lib\net45\Serilog.Sinks.File.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Serilog.Sinks.RollingFile, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.Sinks.RollingFile.3.3.0\lib\net45\Serilog.Sinks.RollingFile.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Data.SQLite, Version=1.0.106.0, Culture=neutral, PublicKeyToken=db937bc2d44ff139, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\System.Data.SQLite.Core.1.0.106.0\lib\net46\System.Data.SQLite.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SolutionInfo.cs">
|
||||
<Link>Properties\SolutionInfo.cs</Link>
|
||||
</Compile>
|
||||
<Compile Include="Helpers\Cleaner.cs" />
|
||||
<Compile Include="Helpers\IdTranslator.cs" />
|
||||
<Compile Include="Helpers\Merger.cs" />
|
||||
<Compile Include="Models\BackupFile.cs" />
|
||||
<Compile Include="BackupFileService.cs" />
|
||||
<Compile Include="Events\ProgressEventArgs.cs" />
|
||||
<Compile Include="Exceptions\BackupFileServicesException.cs" />
|
||||
<Compile Include="Helpers\DataAccessLayer.cs" />
|
||||
<Compile Include="IBackupFileService.cs" />
|
||||
<Compile Include="Models\Database\BlockRange.cs" />
|
||||
<Compile Include="Models\Database\Bookmark.cs" />
|
||||
<Compile Include="Models\Database\Database.cs" />
|
||||
<Compile Include="Models\Database\LastModified.cs" />
|
||||
<Compile Include="Models\Database\Location.cs" />
|
||||
<Compile Include="Models\Database\Note.cs" />
|
||||
<Compile Include="Models\Database\Tag.cs" />
|
||||
<Compile Include="Models\Database\TagMap.cs" />
|
||||
<Compile Include="Models\Database\UserMark.cs" />
|
||||
<Compile Include="Models\ManifestFile\Manifest.cs" />
|
||||
<Compile Include="Models\ManifestFile\UserDataBackup.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Import Project="..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets" Condition="Exists('..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets')" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
|
||||
</PropertyGroup>
|
||||
<Error Condition="!Exists('..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets'))" />
|
||||
</Target>
|
||||
</Project>
|
||||
14
JWLMerge.BackupFileServices/Models/BackupFile.cs
Normal file
14
JWLMerge.BackupFileServices/Models/BackupFile.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace JWLMerge.BackupFileServices.Models
|
||||
{
|
||||
using ManifestFile;
|
||||
|
||||
/// <summary>
|
||||
/// The Backup file.
|
||||
/// </summary>
|
||||
public class BackupFile
|
||||
{
|
||||
public Manifest Manifest { get; set; }
|
||||
|
||||
public Database.Database Database { get; set; }
|
||||
}
|
||||
}
|
||||
46
JWLMerge.BackupFileServices/Models/Database/BlockRange.cs
Normal file
46
JWLMerge.BackupFileServices/Models/Database/BlockRange.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
public class BlockRange
|
||||
{
|
||||
/// <summary>
|
||||
/// The block range identifier.
|
||||
/// </summary>
|
||||
public int BlockRangeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The block type (1 or 2).
|
||||
/// 1 = Publication
|
||||
/// 2 = Bible
|
||||
/// </summary>
|
||||
public int BlockType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph or verse identifier.
|
||||
/// i.e. the one-based paragraph (or verse if a Bible chapter) within the document.
|
||||
/// </summary>
|
||||
public int Identifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The start token.
|
||||
/// i.e. the zero-based word in a sentence that marks the start of the highlight.
|
||||
/// </summary>
|
||||
public int? StartToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The end token.
|
||||
/// i.e. the zero-based word in a sentence that marks the end of the highlight (inclusive).
|
||||
/// </summary>
|
||||
public int? EndToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user mark identifier.
|
||||
/// Refers to userMark.UserMarkId
|
||||
/// </summary>
|
||||
public int UserMarkId { get; set; }
|
||||
|
||||
public BlockRange Clone()
|
||||
{
|
||||
return (BlockRange)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
JWLMerge.BackupFileServices/Models/Database/Bookmark.cs
Normal file
52
JWLMerge.BackupFileServices/Models/Database/Bookmark.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
public class Bookmark
|
||||
{
|
||||
/// <summary>
|
||||
/// The bookmark identifier.
|
||||
/// </summary>
|
||||
public int BookmarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The location identifier.
|
||||
/// Refers to Location.LocationId
|
||||
/// </summary>
|
||||
public int LocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The publication location identifier.
|
||||
/// Refers to Location.LocationId (with Location.Type = 1)
|
||||
/// </summary>
|
||||
public int PublicationLocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The slot in which the bookmark appears.
|
||||
/// i.e. the zero-based order in which it is listed in the UI.
|
||||
/// </summary>
|
||||
public int Slot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The title text.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A snippet of the bookmarked text (can be null)
|
||||
/// </summary>
|
||||
public string Snippet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The block type.
|
||||
/// 1 = Publication
|
||||
/// 2 = Bible
|
||||
/// </summary>
|
||||
public int BlockType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The block identifier.
|
||||
/// The paragraph or verse identifier.
|
||||
/// i.e. the one-based paragraph (or verse if a Bible chapter) within the document.
|
||||
/// </summary>
|
||||
public int? BlockIdentifier { get; set; }
|
||||
}
|
||||
}
|
||||
133
JWLMerge.BackupFileServices/Models/Database/Database.cs
Normal file
133
JWLMerge.BackupFileServices/Models/Database/Database.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
public class Database
|
||||
{
|
||||
private Lazy<Dictionary<string, Note>> _noteIndex;
|
||||
private Lazy<Dictionary<string, UserMark>> _userMarksIndex;
|
||||
private Lazy<Dictionary<int, Location>> _locationsIndex;
|
||||
private Lazy<Dictionary<string, Tag>> _tagsIndex;
|
||||
private Lazy<Dictionary<string, TagMap>> _tagMapIndex;
|
||||
private Lazy<Dictionary<int, BlockRange>> _blockRangeIndex;
|
||||
|
||||
public Database()
|
||||
{
|
||||
ReinitializeIndexes();
|
||||
}
|
||||
|
||||
public void ReinitializeIndexes()
|
||||
{
|
||||
_noteIndex = new Lazy<Dictionary<string, Note>>(NoteIndexValueFactory);
|
||||
_userMarksIndex = new Lazy<Dictionary<string, UserMark>>(UserMarkIndexValueFactory);
|
||||
_locationsIndex = new Lazy<Dictionary<int, Location>>(LocationsIndexValueFactory);
|
||||
_tagsIndex = new Lazy<Dictionary<string, Tag>>(TagIndexValueFactory);
|
||||
_tagMapIndex = new Lazy<Dictionary<string, TagMap>>(TagMapIndexValueFactory);
|
||||
_blockRangeIndex = new Lazy<Dictionary<int, BlockRange>>(BlockRangeIndexValueFactory);
|
||||
}
|
||||
|
||||
public void InitBlank()
|
||||
{
|
||||
LastModified = new LastModified();
|
||||
Locations = new List<Location>();
|
||||
Notes = new List<Note>();
|
||||
Tags = new List<Tag>();
|
||||
TagMaps = new List<TagMap>();
|
||||
BlockRanges = new List<BlockRange>();
|
||||
UserMarks = new List<UserMark>();
|
||||
}
|
||||
|
||||
public LastModified LastModified { get; set; }
|
||||
|
||||
public List<Location> Locations { get; set; }
|
||||
|
||||
public List<Note> Notes { get; set; }
|
||||
|
||||
public List<Tag> Tags { get; set; }
|
||||
|
||||
public List<TagMap> TagMaps { get; set; }
|
||||
|
||||
public List<BlockRange> BlockRanges { get; set; }
|
||||
|
||||
public List<Bookmark> Bookmarks { get; set; }
|
||||
|
||||
public List<UserMark> UserMarks { get; set; }
|
||||
|
||||
public Note FindNote(string guid)
|
||||
{
|
||||
return _noteIndex.Value.TryGetValue(guid, out var note) ? note : null;
|
||||
}
|
||||
|
||||
public UserMark FindUserMark(string guid)
|
||||
{
|
||||
return _userMarksIndex.Value.TryGetValue(guid, out var userMark) ? userMark : null;
|
||||
}
|
||||
|
||||
public Tag FindTag(string tagName)
|
||||
{
|
||||
return _tagsIndex.Value.TryGetValue(tagName, out var tag) ? tag : null;
|
||||
}
|
||||
|
||||
public TagMap FindTagMap(int tagId, int noteId)
|
||||
{
|
||||
return _tagMapIndex.Value.TryGetValue(GetTagMapKey(tagId, noteId), out var tag) ? tag : null;
|
||||
}
|
||||
|
||||
public Location FindLocation(int locationId)
|
||||
{
|
||||
return _locationsIndex.Value.TryGetValue(locationId, out var location) ? location : null;
|
||||
}
|
||||
|
||||
public BlockRange FindBlockRange(int userMarkId)
|
||||
{
|
||||
// note that we find a block range by userMarkId. The BlockRange.UserMarkId column
|
||||
// isn't marked as a unique index, but we assume it should be.
|
||||
return _blockRangeIndex.Value.TryGetValue(userMarkId, out var range) ? range : null;
|
||||
}
|
||||
|
||||
private Dictionary<string, Note> NoteIndexValueFactory()
|
||||
{
|
||||
return Notes.ToDictionary(note => note.Guid);
|
||||
}
|
||||
|
||||
private Dictionary<string, UserMark> UserMarkIndexValueFactory()
|
||||
{
|
||||
return UserMarks.ToDictionary(userMark => userMark.UserMarkGuid);
|
||||
}
|
||||
|
||||
private Dictionary<int, Location> LocationsIndexValueFactory()
|
||||
{
|
||||
return Locations.ToDictionary(location => location.LocationId);
|
||||
}
|
||||
|
||||
private Dictionary<int, BlockRange> BlockRangeIndexValueFactory()
|
||||
{
|
||||
return BlockRanges.ToDictionary(range => range.UserMarkId);
|
||||
}
|
||||
|
||||
private Dictionary<string, Tag> TagIndexValueFactory()
|
||||
{
|
||||
return Tags.ToDictionary(tag => tag.Name);
|
||||
}
|
||||
|
||||
private string GetTagMapKey(int tagId, int noteId)
|
||||
{
|
||||
return $"{tagId}-{noteId}";
|
||||
}
|
||||
|
||||
private Dictionary<string, TagMap> TagMapIndexValueFactory()
|
||||
{
|
||||
var result = new Dictionary<string, TagMap>();
|
||||
|
||||
foreach (var tagMap in TagMaps)
|
||||
{
|
||||
string key = GetTagMapKey(tagMap.TagId, tagMap.TypeId);
|
||||
result.Add(key, tagMap);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
JWLMerge.BackupFileServices/Models/Database/LastModified.cs
Normal file
13
JWLMerge.BackupFileServices/Models/Database/LastModified.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
using Newtonsoft.Json;
|
||||
|
||||
public class LastModified
|
||||
{
|
||||
/// <summary>
|
||||
/// Time stamp when the database was last modified.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "LastModified")]
|
||||
public string TimeLastModified { get; set; }
|
||||
}
|
||||
}
|
||||
65
JWLMerge.BackupFileServices/Models/Database/Location.cs
Normal file
65
JWLMerge.BackupFileServices/Models/Database/Location.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
/// <summary>
|
||||
/// The Location table row.
|
||||
/// </summary>
|
||||
public class Location
|
||||
{
|
||||
/// <summary>
|
||||
/// The location identifier.
|
||||
/// </summary>
|
||||
public int LocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Bible book number (or null if not Bible).
|
||||
/// </summary>
|
||||
public int? BookNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Bible chapter number (or null if not Bible).
|
||||
/// </summary>
|
||||
public int? ChapterNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The JWL document identifier.
|
||||
/// </summary>
|
||||
public int? DocumentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The track. Semantics unknown!
|
||||
/// </summary>
|
||||
public int? Track { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A refernce to the publication issue (if applicable), e.g. "20171100"
|
||||
/// </summary>
|
||||
public int IssueTagNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The JWL publication key symbol.
|
||||
/// </summary>
|
||||
public string KeySymbol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The MEPS identifier for the publication language.
|
||||
/// </summary>
|
||||
public int MepsLanguage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type.
|
||||
/// 0 = standard location entry
|
||||
/// 1 = reference to a publication (see Bookmark.PublicationLocationId)
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A location title (nullable).
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
public Location Clone()
|
||||
{
|
||||
return (Location)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
JWLMerge.BackupFileServices/Models/Database/Note.cs
Normal file
71
JWLMerge.BackupFileServices/Models/Database/Note.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
using System;
|
||||
|
||||
public class Note
|
||||
{
|
||||
/// <summary>
|
||||
/// The note identifier.
|
||||
/// </summary>
|
||||
public int NoteId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A Guid (that should assist in merging notes).
|
||||
/// </summary>
|
||||
public string Guid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user mark identifier (if the note is associated with user-highlighting).
|
||||
/// A reference to UserMark.UserMarkId
|
||||
/// </summary>
|
||||
public int? UserMarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The location identifier (if the note is associated with a location - which it usually is)
|
||||
/// </summary>
|
||||
public int? LocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user-defined note title.
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user-defined note content.
|
||||
/// </summary>
|
||||
public string Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time stamp when the note was last edited. ISO 8601 format.
|
||||
/// </summary>
|
||||
public string LastModified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of block associated with the note.
|
||||
/// Valid values are possibly 0, 1, and 2.
|
||||
/// Best guess at semantics:
|
||||
/// 0 = The note is associated with the document rather than a block of text within it.
|
||||
/// 1 = The note is associated with a paragraph in a publication.
|
||||
/// 2 = The note is associated with a verse in the Bible.
|
||||
/// In all cases, see also the UserMarkId which may better define the associated block of text.
|
||||
/// </summary>
|
||||
public int BlockType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The block identifier. Helps to locate the block of text associated with the note.
|
||||
/// If the BlockType is 1 (a publication), then BlockIdentifier denotes the paragraph number.
|
||||
/// If the BlockType is 2 (the Bible), then BlockIdentifier denotes the verse number.
|
||||
/// </summary>
|
||||
public int? BlockIdentifier { get; set; }
|
||||
|
||||
public DateTime GetLastModifiedDateTime()
|
||||
{
|
||||
return DateTime.Parse(LastModified);
|
||||
}
|
||||
|
||||
public Note Clone()
|
||||
{
|
||||
return (Note)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
26
JWLMerge.BackupFileServices/Models/Database/Tag.cs
Normal file
26
JWLMerge.BackupFileServices/Models/Database/Tag.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
public class Tag
|
||||
{
|
||||
/// <summary>
|
||||
/// The tag identifier.
|
||||
/// </summary>
|
||||
public int TagId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tag type.
|
||||
/// There appear to be 2 tag types (0 = Favourite, 1 = User-defined).
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the tag.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
public Tag Clone()
|
||||
{
|
||||
return (Tag)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
JWLMerge.BackupFileServices/Models/Database/TagMap.cs
Normal file
39
JWLMerge.BackupFileServices/Models/Database/TagMap.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
public class TagMap
|
||||
{
|
||||
/// <summary>
|
||||
/// The tag map identifier.
|
||||
/// </summary>
|
||||
public int TagMapId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of data that the tag is attached to.
|
||||
/// Currently it looks like there is only 1 'type' - a Note (value = 1).
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The identifier of the data that the tag is attached to.
|
||||
/// Currently it looks like this always refers to Note.NoteId
|
||||
/// </summary>
|
||||
public int TypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tag identifier.
|
||||
/// Refers to Tag.TagId.
|
||||
/// </summary>
|
||||
public int TagId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The zero-based position of the tag map entry (among all entries having the same TagId).
|
||||
/// (Tagged items can be ordered in the JWL application.)
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
public TagMap Clone()
|
||||
{
|
||||
return (TagMap)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
JWLMerge.BackupFileServices/Models/Database/UserMark.cs
Normal file
41
JWLMerge.BackupFileServices/Models/Database/UserMark.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.Database
|
||||
{
|
||||
public class UserMark
|
||||
{
|
||||
/// <summary>
|
||||
/// The user mark identifier.
|
||||
/// </summary>
|
||||
public int UserMarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The index of the marking (highlight) color.
|
||||
/// </summary>
|
||||
public int ColorIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The location identifier.
|
||||
/// Refers to Location.LocationId
|
||||
/// </summary>
|
||||
public int LocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The style index (unused?)
|
||||
/// </summary>
|
||||
public int StyleIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The guid. Useful in merging!
|
||||
/// </summary>
|
||||
public string UserMarkGuid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highlight version. Semantics unknown!
|
||||
/// </summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
public UserMark Clone()
|
||||
{
|
||||
return (UserMark)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
JWLMerge.BackupFileServices/Models/ManifestFile/Manifest.cs
Normal file
38
JWLMerge.BackupFileServices/Models/ManifestFile/Manifest.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.ManifestFile
|
||||
{
|
||||
/// <summary>
|
||||
/// The manifest file.
|
||||
/// </summary>
|
||||
public class Manifest
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the backup file (without the "jwlibrary" extension).
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The local creation date in the form "YYYY-MM-DD"
|
||||
/// </summary>
|
||||
public string CreationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The manifest schema version.
|
||||
/// </summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type. Semantics unknown!
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of the backup database.
|
||||
/// </summary>
|
||||
public UserDataBackup UserDataBackup { get; set; }
|
||||
|
||||
public Manifest Clone()
|
||||
{
|
||||
return (Manifest)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace JWLMerge.BackupFileServices.Models.ManifestFile
|
||||
{
|
||||
/// <summary>
|
||||
/// The user data backup.
|
||||
/// Part of the manifest.
|
||||
/// </summary>
|
||||
public class UserDataBackup
|
||||
{
|
||||
/// <summary>
|
||||
/// The last modified date of the database in ISO 8601, e.g. "2018-01-17T14:37:27+00:00"
|
||||
/// Corresponds to the value in the LastModifiedDate table.
|
||||
/// </summary>
|
||||
public string LastModifiedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the source device (e.g. the name of the PC).
|
||||
/// </summary>
|
||||
public string DeviceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The database name (always "userData.db"?)
|
||||
/// </summary>
|
||||
public string DatabaseName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A sha256 hash of the associated database file.
|
||||
/// </summary>
|
||||
public string Hash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The database schema version.
|
||||
/// Note that the database records its own schema version in the user_version header pragma.
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; }
|
||||
}
|
||||
}
|
||||
9
JWLMerge.BackupFileServices/Properties/AssemblyInfo.cs
Normal file
9
JWLMerge.BackupFileServices/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("JWLMerge.BackupFileServices")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
8
JWLMerge.BackupFileServices/packages.config
Normal file
8
JWLMerge.BackupFileServices/packages.config
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Newtonsoft.Json" version="10.0.3" targetFramework="net462" />
|
||||
<package id="Serilog" version="2.6.0" targetFramework="net462" />
|
||||
<package id="Serilog.Sinks.File" version="3.2.0" targetFramework="net462" />
|
||||
<package id="Serilog.Sinks.RollingFile" version="3.3.0" targetFramework="net462" />
|
||||
<package id="System.Data.SQLite.Core" version="1.0.106.0" targetFramework="net462" />
|
||||
</packages>
|
||||
42
JWLMerge.sln
Normal file
42
JWLMerge.sln
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.27130.2024
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JWLMerge.BackupFileServices", "JWLMerge.BackupFileServices\JWLMerge.BackupFileServices.csproj", "{83446629-CDBB-43FF-B628-1B8A3A9603C3}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JWL Database Schemas", "JWL Database Schemas", "{DA7BDF58-CFEA-489C-B18C-944D9986758D}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
readme.txt = readme.txt
|
||||
Version005.txt = Version005.txt
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JWLMergeCLI", "JWLMergeCLI\JWLMergeCLI.csproj", "{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DA506BB2-675E-47BE-BC54-ED6EAE369243}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
SolutionInfo.cs = SolutionInfo.cs
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{83446629-CDBB-43FF-B628-1B8A3A9603C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{83446629-CDBB-43FF-B628-1B8A3A9603C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{83446629-CDBB-43FF-B628-1B8A3A9603C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{83446629-CDBB-43FF-B628-1B8A3A9603C3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {25CF4C37-61F2-4F17-B1D5-F2EF80D9A55B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
6
JWLMergeCLI/App.config
Normal file
6
JWLMergeCLI/App.config
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
14
JWLMergeCLI/Exceptions/JWLMergeCLIException.cs
Normal file
14
JWLMergeCLI/Exceptions/JWLMergeCLIException.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace JWLMergeCLI.Exceptions
|
||||
{
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public class JWLMergeCLIException : Exception
|
||||
{
|
||||
public JWLMergeCLIException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
86
JWLMergeCLI/JWLMergeCLI.csproj
Normal file
86
JWLMergeCLI/JWLMergeCLI.csproj
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{2CBBA1C2-72C9-4287-A262-EC1D2A2F6E56}</ProjectGuid>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>JWLMergeCLI</RootNamespace>
|
||||
<AssemblyName>JWLMergeCLI</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Serilog, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.2.6.0\lib\net46\Serilog.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Serilog.Sinks.File, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.Sinks.File.3.2.0\lib\net45\Serilog.Sinks.File.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Serilog.Sinks.RollingFile, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Serilog.Sinks.RollingFile.3.3.0\lib\net45\Serilog.Sinks.RollingFile.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Data.SQLite, Version=1.0.106.0, Culture=neutral, PublicKeyToken=db937bc2d44ff139, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\System.Data.SQLite.Core.1.0.106.0\lib\net46\System.Data.SQLite.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SolutionInfo.cs">
|
||||
<Link>Properties\SolutionInfo.cs</Link>
|
||||
</Compile>
|
||||
<Compile Include="Exceptions\JWLMergeCLIException.cs" />
|
||||
<Compile Include="MainApp.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JWLMerge.BackupFileServices\JWLMerge.BackupFileServices.csproj">
|
||||
<Project>{83446629-cdbb-43ff-b628-1b8a3a9603c3}</Project>
|
||||
<Name>JWLMerge.BackupFileServices</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Import Project="..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets" Condition="Exists('..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets')" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
|
||||
</PropertyGroup>
|
||||
<Error Condition="!Exists('..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\System.Data.SQLite.Core.1.0.106.0\build\net46\System.Data.SQLite.Core.targets'))" />
|
||||
</Target>
|
||||
</Project>
|
||||
79
JWLMergeCLI/MainApp.cs
Normal file
79
JWLMergeCLI/MainApp.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
namespace JWLMergeCLI
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Exceptions;
|
||||
using JWLMerge.BackupFileServices;
|
||||
using JWLMerge.BackupFileServices.Events;
|
||||
using Serilog;
|
||||
|
||||
/// <summary>
|
||||
/// The main app.
|
||||
/// </summary>
|
||||
internal sealed class MainApp
|
||||
{
|
||||
public event EventHandler<ProgressEventArgs> ProgressEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the app.
|
||||
/// </summary>
|
||||
/// <param name="args">Program arguments</param>
|
||||
public void Run(string[] args)
|
||||
{
|
||||
var files = GetInputFiles(args);
|
||||
|
||||
IBackupFileService backupFileService = new BackupFileService();
|
||||
backupFileService.ProgressEvent += BackupFileServiceProgress;
|
||||
|
||||
var backup = backupFileService.Merge(files);
|
||||
string outputFileName = $"{backup.Manifest.Name}.jwlibrary";
|
||||
backupFileService.WriteNewDatabase(backup, outputFileName, files.First());
|
||||
|
||||
var logMessage = $"{files.Count} backup files merged to {outputFileName}";
|
||||
Log.Logger.Information(logMessage);
|
||||
OnProgressEvent(logMessage);
|
||||
}
|
||||
|
||||
private void BackupFileServiceProgress(object sender, ProgressEventArgs e)
|
||||
{
|
||||
OnProgressEvent(e);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetInputFiles(string[] args)
|
||||
{
|
||||
OnProgressEvent("Checking files exist...");
|
||||
|
||||
var result = new List<string>();
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (!File.Exists(arg))
|
||||
{
|
||||
throw new JWLMergeCLIException($"File does not exist: {arg}");
|
||||
}
|
||||
|
||||
Log.Logger.Debug("Found file: {file}", arg);
|
||||
result.Add(arg);
|
||||
}
|
||||
|
||||
if (result.Count < 2)
|
||||
{
|
||||
throw new JWLMergeCLIException("Specify at least 2 files to merge");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void OnProgressEvent(ProgressEventArgs e)
|
||||
{
|
||||
ProgressEvent?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private void OnProgressEvent(string message)
|
||||
{
|
||||
OnProgressEvent(new ProgressEventArgs { Message = message });
|
||||
}
|
||||
}
|
||||
}
|
||||
81
JWLMergeCLI/Program.cs
Normal file
81
JWLMergeCLI/Program.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace JWLMergeCLI
|
||||
{
|
||||
using System;
|
||||
using Serilog;
|
||||
|
||||
public class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point.
|
||||
/// </summary>
|
||||
/// <param name="args">
|
||||
/// The args.
|
||||
/// </param>
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.RollingFile("logs\\log-{Date}.txt")
|
||||
.MinimumLevel.Debug()
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Started");
|
||||
|
||||
if (args.Length < 2)
|
||||
{
|
||||
ShowUsage();
|
||||
}
|
||||
else
|
||||
{
|
||||
var app = new MainApp();
|
||||
app.ProgressEvent += AppProgress;
|
||||
app.Run(args);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
Log.Logger.Information("Finished");
|
||||
}
|
||||
// ReSharper disable once CatchAllClause
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error");
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine(ex.Message);
|
||||
Console.ResetColor();
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
private static void ShowUsage()
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("Description:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(" JWLMergeCLI is used to merge the contents of 2 or more jwlibrary backup");
|
||||
Console.WriteLine(" files. These files are produced by the JW Library backup command and");
|
||||
Console.WriteLine(" contain your personal study notes and highlighting.");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("Usage:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(" JWLMergeCLI <jwlibrary file 1> <jwlibrary file 2>...");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("An example:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(" JWLMergeCLI \"C:\\Backup_PC16.jwlibrary\" \"C:\\Backup_iPad.jwlibrary\"");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static void AppProgress(object sender, JWLMerge.BackupFileServices.Events.ProgressEventArgs e)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
JWLMergeCLI/Properties/AssemblyInfo.cs
Normal file
12
JWLMergeCLI/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("JWLMergeCLI")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
7
JWLMergeCLI/packages.config
Normal file
7
JWLMergeCLI/packages.config
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Serilog" version="2.6.0" targetFramework="net462" />
|
||||
<package id="Serilog.Sinks.File" version="3.2.0" targetFramework="net462" />
|
||||
<package id="Serilog.Sinks.RollingFile" version="3.3.0" targetFramework="net462" />
|
||||
<package id="System.Data.SQLite.Core" version="1.0.106.0" targetFramework="net462" />
|
||||
</packages>
|
||||
10
SolutionInfo.cs
Normal file
10
SolutionInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyCompany("Antony Corbett")]
|
||||
[assembly: AssemblyProduct("JWLMerge")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2018")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
[assembly: AssemblyVersion("1.0.0.6")]
|
||||
|
||||
88
Version005.txt
Normal file
88
Version005.txt
Normal file
@@ -0,0 +1,88 @@
|
||||
CREATE TABLE BlockRange (
|
||||
BlockRangeId INTEGER NOT NULL PRIMARY KEY,
|
||||
BlockType INTEGER NOT NULL,
|
||||
Identifier INTEGER NOT NULL,
|
||||
StartToken INTEGER,
|
||||
EndToken INTEGER,
|
||||
UserMarkId INTEGER NOT NULL,
|
||||
CHECK (BlockType BETWEEN 1 AND 2),
|
||||
FOREIGN KEY(UserMarkId) REFERENCES UserMark(UserMarkId)
|
||||
);
|
||||
|
||||
CREATE TABLE Bookmark(
|
||||
BookmarkId INTEGER NOT NULL PRIMARY KEY,
|
||||
LocationId INTEGER NOT NULL,
|
||||
PublicationLocationId INTEGER NOT NULL,
|
||||
Slot INTEGER NOT NULL,
|
||||
Title TEXT NOT NULL,
|
||||
Snippet TEXT,
|
||||
BlockType INTEGER NOT NULL DEFAULT 0,
|
||||
BlockIdentifier INTEGER,
|
||||
FOREIGN KEY(LocationId) REFERENCES Location(LocationId),
|
||||
FOREIGN KEY(PublicationLocationId) REFERENCES Location(LocationId),
|
||||
CONSTRAINT PublicationLocationId_Slot UNIQUE (PublicationLocationId, Slot));
|
||||
|
||||
CREATE TABLE LastModified(LastModified TEXT NOT NULL DEFAULT(strftime('%Y-%m-%dT%H:%M:%SZ', 'now')));
|
||||
|
||||
CREATE TABLE Location (
|
||||
LocationId INTEGER NOT NULL PRIMARY KEY,
|
||||
BookNumber INTEGER,
|
||||
ChapterNumber INTEGER,
|
||||
DocumentId INTEGER,
|
||||
Track INTEGER,
|
||||
IssueTagNumber INTEGER NOT NULL DEFAULT 0,
|
||||
KeySymbol TEXT NOT NULL,
|
||||
MepsLanguage INTEGER NOT NULL,
|
||||
Type INTEGER NOT NULL,
|
||||
Title TEXT,
|
||||
CHECK (
|
||||
(Type = 0 AND (DocumentId IS NOT NULL AND DocumentId != 0) AND BookNumber IS NULL AND ChapterNumber IS NULL AND Track IS NULL) OR
|
||||
(Type = 0 AND DocumentId IS NULL AND (BookNumber IS NOT NULL AND BookNumber != 0) AND (ChapterNumber IS NOT NULL AND ChapterNumber != 0) AND Track IS NULL) OR
|
||||
(Type = 1 AND BookNumber IS NULL AND ChapterNumber IS NULL AND DocumentId IS NULL AND Track IS NULL) OR
|
||||
(Type IN (2, 3) AND BookNumber IS NULL AND ChapterNumber IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE Note( NoteId INTEGER NOT NULL PRIMARY KEY, Guid TEXT NOT NULL UNIQUE, UserMarkId INTEGER, LocationId INTEGER, Title TEXT, Content TEXT, LastModified TEXT NOT NULL DEFAULT(strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), BlockType INTEGER NOT NULL DEFAULT 0, BlockIdentifier INTEGER, CHECK ((BlockType = 0 AND BlockIdentifier IS NULL) OR (BlockType != 0 AND BlockIdentifier IS NOT NULL)));
|
||||
|
||||
CREATE TABLE Tag(TagId INTEGER NOT NULL PRIMARY KEY, Type INTEGER NOT NULL,Name TEXT NOT NULL,UNIQUE (Type, Name), CHECK (length(Name) > 0));
|
||||
|
||||
CREATE TABLE TagMap (
|
||||
TagMapId INTEGER NOT NULL PRIMARY KEY,
|
||||
Type INTEGER NOT NULL,
|
||||
TypeId INTEGER NOT NULL,
|
||||
TagId INTEGER NOT NULL,
|
||||
Position INTEGER NOT NULL,
|
||||
FOREIGN KEY(TagId) REFERENCES Tag(TagId)
|
||||
CONSTRAINT Type_TypeId_TagId_Position UNIQUE (Type, TypeId, TagId, Position));
|
||||
|
||||
CREATE TABLE UserMark (
|
||||
UserMarkId INTEGER NOT NULL PRIMARY KEY,
|
||||
ColorIndex INTEGER NOT NULL,
|
||||
LocationId INTEGER NOT NULL,
|
||||
StyleIndex INTEGER NOT NULL,
|
||||
UserMarkGuid TEXT NOT NULL UNIQUE,
|
||||
Version INTEGER NOT NULL,
|
||||
CHECK (LocationId > 0),
|
||||
FOREIGN KEY(LocationId) REFERENCES Location(LocationId)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_BlockRange_UserMarkId ON BlockRange(UserMarkId);
|
||||
|
||||
CREATE INDEX IX_Location_KeySymbol_MepsLanguage_BookNumber_ChapterNumber ON
|
||||
Location(KeySymbol, MepsLanguage, BookNumber, ChapterNumber);
|
||||
|
||||
CREATE INDEX IX_Location_MepsLanguage_DocumentId ON Location(MepsLanguage, DocumentId);
|
||||
|
||||
CREATE INDEX IX_Note_LastModified_LocationId ON Note(LastModified, LocationId);
|
||||
|
||||
CREATE INDEX IX_Note_LocationId_BlockIdentifier ON Note(LocationId, BlockIdentifier);
|
||||
|
||||
CREATE INDEX IX_TagMap_TagId ON TagMap(TagId);
|
||||
|
||||
CREATE INDEX IX_TagMap_TypeId_TagId_Position ON TagMap(TypeId, Type, TagId, Position);
|
||||
|
||||
CREATE INDEX IX_Tag_Name_Type_TagId ON Tag(Name, Type, TagId);
|
||||
|
||||
CREATE INDEX IX_UserMark_LocationId ON UserMark(LocationId);
|
||||
|
||||
1
readme.txt
Normal file
1
readme.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is a record of the sqlite database schema used by the userData.db file. The DDL is extracted using SQLite Expert.
|
||||
Reference in New Issue
Block a user