Library Commander

0
155

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.

DataModel

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. 33 - Going Postal.epub"

Document are added to Library via BookCard dialog:

Book Card

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:

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

How to use

To use LibraryCommander

Download LibraryCommander.zip (GitHub release)

Change “library” folder in LibraryCommander.exe.config file

Run LibraryCommander.exe

LEAVE A REPLY