Introduction
LibraryCommander is a personal desktop application to manage my texts (e-book) collection, classify and search them by categories and tags.
LibraryCommander uses SQLite database to keep documents metadata (title, authors, language, etc) but stores actual files on disk in predefined folder (creates additional folders inside to categorize documents based on their metadata). Files are easily accessible from the application, Windows Explorer or any other file manager.
Data Model
Let's review application data model before discussing storage structure.
The central class is of course Book
. A book belongs to a certain Category
and can have multiple Tags
(at least one). A book is written in one Language
by one or many Authors
. A set of books can form a series (Cycle
, e.g. "Diskworld" by Terry Pratchett), a book has Volume
attribute to store its number in the series. The same book can be in different Formats
(file extensions).
For each Category there is a separate folder in the storage directory. Category folders have subfolders for every Language. Books are stored in language folders unless user want to create additional folder for author or cycle when adding a book to the library.
Consider "Discworld" series by Terry Pratchett, for example. One could put it in "Fantasy" category and prefer to have authors folder ("Terry Pratchett") and series folder ("Discworld") to store other Pratchett works separately. So the final location of a book would be
"Library\Fantasy\En\Terry Pratchett\Discworld\Terry Pratchett. 16 - Soul Music.epub"
Document are added to Library via BookCard dialog:
Each document should have title, category, author(s), tag(s), language, format(s) and file location to copy. If file is selected before entering title and format, then title and format are taken from file name. Cycle, volume and (publishing) year are optional attributes. Tags and cycles are associated with a concrete category and cannot be selected before category.
Books from the same cycle usually have the same attributes (except Title and file location). To simplify the task of adding multiple books LibraryCommander has template functionality: enter attributes of the first book, copy template (Ctrl+C) and when adding the next book paste attributes from template (Ctrl+V).
LibraryCommander navigation
LibraryCommander was inspired by orthodox file managers. Take a look at the main screen:
It presents a two-panel directory view (Files and Library panel) with a command list below. Each panels show current folder path and list of files/subfolders. Only one of the panels is active at a given time. The active panel contains the "cursor". Files in the active panel serve as the parameters of operations.
Data for panels are provided by so called FsNavigator
classes (FileSystem Navigator). Given initial path FsNavigator
returns list of files and folders by that path, wrapped in FsItem
objects (with Properties Name, Size, Extension (for files) and IsDirectory flag to differentiate files/folders).
Navigator for Files panel is trivial and uses DirectoryInfo.EnumerateDirectories()
and DirectoryInfo.EnumerateFiles()
method to get all elements in current folder.
Navigator for Library panel (VirtualFsNavigator
class) works based on documents metadata. It can check if a book was added to library but its file is missing in the storage. It also ignores files which exist in storage folders but not in a library.
VirtualFsNavigator selects files and folders based on current level in the storage.
- Storage Top
level
- no files
- folders for each Category
- Storage Category
level
- no files
- folders for each Language
- Storage Language
level
- files for books in current Category and Language which don't have Author or Cycle subfolder
- folders for Author and Cycle, selected from books in current Category and Language which have subfolder
- Storage Author
level
- files for books in current Category and Language which have Author subfolder
- folders for Cycle subfolder, selected from books of current Author in current Category and Language
- Storage Cycle
level
- files for books of current Author in current Category and Language which have Cycle subfolder
- no folders
Hotkeys
Functional buttons in LibraryCommander have associated hotkeys. Hotkeys in WPF can be easily created using InputBindings
. However with a large number of functions it becomes tedious to add all of them to a window InputBindings. To speed up the process and clearly associate hotkeys with certain buttons I created string attached DependencyProperty for Button class called "Hotkey
". When "Hotkey
" is assigned (e.g. "Control+F" or "F1") string is parsed in property changed callback in Cmd
class and if key and modifiers are correct, InputBinding is added to Button window. Here is Cmd
code:
public static class Cmd
{
public static readonly DependencyProperty HotkeyProperty =
DependencyProperty.RegisterAttached("Hotkey", typeof(string), typeof(Cmd), new PropertyMetadata(null, HotkeyChangedCallback));
public static string GetHotkey(DependencyObject obj)
{
return (string)obj.GetValue(HotkeyProperty);
}
public static void SetHotkey(DependencyObject obj, string value)
{
obj.SetValue(HotkeyProperty, value);
}
private static readonly char _cmdJoinChar = '+';
private static readonly char _cmdNameChar = '_';
private static string NormalizeName(string name)
{
return name.Replace(_cmdJoinChar, _cmdNameChar);
}
private static void HotkeyChangedCallback(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
var btn = obj as Button;
if (btn == null)
return;
Window parentWindow = Window.GetWindow(btn);
if (parentWindow == null)
return;
KeyBinding kb = null;
string hkOld = (string) e.OldValue;
if (false == String.IsNullOrWhiteSpace(hkOld))
{
hkOld = NormalizeName(hkOld);
kb = parentWindow.InputBindings
.OfType<KeyBinding>()
.FirstOrDefault(k => hkOld == (string) k.GetValue(FrameworkElement.NameProperty));
if (kb != null)
parentWindow.InputBindings.Remove(kb);
}
string hkNew = (string) e.NewValue;
if (String.IsNullOrWhiteSpace(hkNew))
return;
var keys = hkNew.Split(new [] { _cmdJoinChar }, StringSplitOptions.RemoveEmptyEntries);
ModifierKeys modifier = ModifierKeys.None;
ModifierKeys m;
string strKey = null;
foreach (string k in keys)
{
if (Enum.TryParse(k, out m))
modifier = modifier | m;
else
{
if (strKey != null)
return;
strKey = k;
}
}
Key key;
if (false == Enum.TryParse(strKey, out key))
return;
kb = new KeyBinding {Key = key, Modifiers = modifier};
kb.SetValue(FrameworkElement.NameProperty, NormalizeName(hkNew));
var cmdBinding = new Binding("Command") {Source = btn};
BindingOperations.SetBinding(kb, InputBinding.CommandProperty, cmdBinding);
var paramBinding = new Binding("CommandParameter") {Source = btn};
BindingOperations.SetBinding(kb, InputBinding.CommandParameterProperty, paramBinding);
parentWindow.InputBindings.Add(kb);
}
}
List of LibraryCommander hotkeys:
Main screen
- Control+{Number}: select existing partition (Number >= 1)
- Tab: switch active panel
- Arrows Up and Down: move to previous/next file/folder in list
- Enter: open selected file/folder
- Escape: go to Parent folder from nested folder
- F2: open selected file/folder
- F3: edit selected book (active only on Library panel)
- F4: create new book (active only on Library panel)
- F5: copy selected file to Library (active only on Files panel)
- F6: move selected file to Library (active only on Files panel)
- F8: delete selected book (active only on Library panel)
- Control+F: open Library Search dialog (active only on Library panel)
BookCard window
- Control+C: copy template
- Control+V: paste template
- Control+O: select book file
- Control+A: select Author
- Control+K: select Category
- Control+T: select Tags
- Control+L: select Language
- Control+E: select file format (Extension)
- Escape: closes BookCard window, selection window (for authors, categories, etc) and InputBoxes.
Localization
LibraryCommander supports two languages. English is default and it switches to Russian when appropriate machine locale detected. Languages can also be switched on main screen.
Localization approach is described in the "Localization for Dummies" CodeProject article. There is a set of string resources for each language (En, Ru). The article suggests {x:Static}
extension to access resources from xaml, but it doesn't help to switch language at runtime. I created LocalizationProvider
class which stores default culture, current culture, can get resource values by key and update values when culture was switched (implements INotifyPropertyChanged
):
private Dictionary<string, string> _cache = new Dictionary<string, string>();
protected string GetResource([CallerMemberName]string resourceKey = null)
{
string resource;
if (_cache.TryGetValue(resourceKey, out resource))
return resource;
resource = Resources.ResourceManager.GetString(resourceKey, CurrentCulture);
if (resource == null && CurrentCulture.Name != DefaultCulture.Name)
resource = Resources.ResourceManager.GetString(resourceKey, DefaultCulture);
if (resource == null)
resource = resourceKey;
_cache.Add(resourceKey, resource);
return resource;
}
Getting resource values isn't a one-step process so LocalizationProvider
uses string cache. LocalizationProvider
is a base class and different localizations are supposed to be implemented as derived classes. An example of such implementation is Commands
class with the names of main screen functional buttons:
public class Commands: LocalizationProvider
{
private static Commands _instance = new Commands();
private Commands()
{
}
public static Commands Instance { get { return _instance; } }
public string Cmd { get { return GetResource(); } }
public string Pick { get { return GetResource(); } }
public string Add { get { return GetResource(); } }
public string Edit { get { return GetResource(); } }
public string Copy { get { return GetResource(); } }
public string Move { get { return GetResource(); } }
public string Del { get { return GetResource(); } }
public string Quit { get { return GetResource(); } }
public string Search { get { return GetResource(); } }
public string Save { get { return GetResource(); } }
public string Close { get { return GetResource(); } }
}
CallerMemberName
attribute on method parameter shortens implementation to one method call (provided that property name and resource key match).
Buttons content in the view is set via binding expression, e.g.:
{Binding Path=Quit, Source={x:Static localization:Commands.Instance}}
Points of Interest
LibraryCommander is translated in two languages (En, Ru). Localized values are stored in project Resources. Application culture can be switched in runtume.
LibraryCommander has multiple keyboard shortcuts. Shortcuts in dialogs are implemented in the form of Window.KeyBindings
. Custom attached property Cmd.Hotkey
helps to reduce length of markup and clearly associate hotkeys with certain buttons.
LibraryCommander uses custom WPF styles to imitate old-school applications. Collection of styles (codename RetroUI
) is my creation and includes Button, CheckBox, RadioButton, TabControl, ListBox, ComboBox, DataGrid, TreeView controls and can be found in my GitHub repository: https://github.com/AlexanderSharykin/RetroUI
How to use
To use LibraryCommander
Download LibraryCommander.zip
(GitHub release)
Change "library" folder in LibraryCommander.exe.config
file
Run LibraryCommander.exe
Application performs config verification at startup. LibraryCommander requires storage folder for documents and database for metadata.
Storage folder path should be provided in .config file (section <appSettings>
, key "library"
). If folder is not found, verification shows error message with description of a problem and exit application.
LibraryCommander release comes with Books.db file and SQLite connection by default. To launch project from IDE modify file path in SQLite connection string. Books.db file with empty tables can be found in sources in "Db" folder. There is also a script ("SqlServer Db Schema.sql") to create LibraryCommander database in SQL Server. An example of SQL Server connection settings is provided in .config file.