Add project files.

This commit is contained in:
AntonyCorbett
2018-01-21 07:42:02 +00:00
parent 8947dd1b6c
commit 5fda4b8e54
34 changed files with 2350 additions and 0 deletions

View 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);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace JWLMerge.BackupFileServices.Events
{
using System;
public class ProgressEventArgs : EventArgs
{
public string Message { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
namespace JWLMerge.BackupFileServices.Exceptions
{
using System;
[Serializable]
public class BackupFileServicesException : Exception
{
public BackupFileServicesException(string errorMessage)
: base(errorMessage)
{
}
}
}

View 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;
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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);
}
}
}

View 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);
}
}

View 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>

View 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; }
}
}

View 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();
}
}
}

View 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; }
}
}

View 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;
}
}
}

View 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; }
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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();
}
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,9 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("JWLMerge.BackupFileServices")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: ComVisible(false)]

View 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
View 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
View 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>

View 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)
{
}
}
}

View 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
View 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
View 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);
}
}
}

View 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)]

View 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
View 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
View 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
View 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.