Просмотр исходного кода

Add managed book storage and metadata extraction

Bernardo Magri 4 месяцев назад
Родитель
Сommit
d598c40dca
3 измененных файлов с 285 добавлено и 44 удалено
  1. 72 33
      src/main.pas
  2. 161 0
      src/unitmetadata.pas
  3. 52 11
      src/unitsettingsdialog.pas

+ 72 - 33
src/main.pas

@@ -7,7 +7,7 @@ interface
 uses
 uses
   Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls, LazFileUtils,
   Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls, LazFileUtils,
   Book, BookCollection, LCLIntf, LResources, StdCtrls, LCLType, IniFiles, unitSettingsDialog,
   Book, BookCollection, LCLIntf, LResources, StdCtrls, LCLType, IniFiles, unitSettingsDialog,
-  unitCoverWorker, unitStorageXML;
+  unitCoverWorker, unitStorageXML, unitMetadata;
 
 
 
 
 type
 type
@@ -56,8 +56,10 @@ var
   BookList:TBookCollection;
   BookList:TBookCollection;
   Xspace, Yspace:integer;
   Xspace, Yspace:integer;
   dataPath:String;
   dataPath:String;
+  booksDir:String;
   background,toolbar:TPicture;
   background,toolbar:TPicture;
   bookWidth,bookHeight:Integer;
   bookWidth,bookHeight:Integer;
+  optCopyBooks,optRenameBooks,optExtractMeta:Boolean;
 
 
 
 
 
 
@@ -302,21 +304,63 @@ procedure Tform1.ButtonAddClick(Sender: TObject);
 var
 var
   book:TBook;
   book:TBook;
   i:Integer;
   i:Integer;
+  src,dest,fname,title,authors,ext:String;
+
+  function CleanName(const s:String):String;
+  const bad = '/\?*:<>|"';
+  var c:Char;
+  begin
+    Result := Trim(s);
+    for c in bad do
+      Result := StringReplace(Result, c, '_', [rfReplaceAll]);
+  end;
 begin
 begin
 
 
 if OpenDialog1.Execute then
 if OpenDialog1.Execute then
 begin
 begin
   for i:= 0 to Opendialog1.Files.Count-1 do
   for i:= 0 to Opendialog1.Files.Count-1 do
   begin
   begin
-  book:=TBook.Create(PanelBackground);
-  book.FilePath:= OpenDialog1.Files.Strings[i];
-  BookList.AddBook(book);
-  book.Cover.Width:=bookWidth;
-  book.Cover.Height:=bookHeight;
-  book.Cover.Parent:=PanelBackground;
-   
+    src := OpenDialog1.Files.Strings[i];
+    dest := src;
+    title := '';
+    authors := '';
+    if optExtractMeta then
+      ExtractBookMetadata(src, title, authors);
+
+    if optCopyBooks then
+    begin
+      ForceDirectories(booksDir);
+      fname := ExtractFileName(src);
+      if optRenameBooks and (title <> '') then
+      begin
+        ext := ExtractFileExt(src);
+        fname := CleanName(title);
+        if authors <> '' then
+          fname := fname + ' - ' + CleanName(authors);
+        fname := fname + ext;
+      end;
+      dest := IncludeTrailingPathDelimiter(booksDir) + fname;
+      CopyFile(src, dest);
+    end;
+
+    book:=TBook.Create(PanelBackground);
+    book.FilePath:= dest;
+    if optExtractMeta then
+    begin
+      if title <> '' then book.Title := title
+      else book.Title := ChangeFileExt(ExtractFileName(dest), '');
+      if authors <> '' then book.Authors := authors;
+    end
+    else
+      book.Title := ChangeFileExt(ExtractFileName(dest), '');
+
+    BookList.AddBook(book);
+    book.Cover.Width:=bookWidth;
+    book.Cover.Height:=bookHeight;
+    book.Cover.Parent:=PanelBackground;
+    CoverWorkerEnqueueBookIfMissing(book);
+
   end;
   end;
-  CoverWorkerEnqueueBookIfMissing(Book);
   CoverWorkerStart;
   CoverWorkerStart;
   RearrangeBooksOnScreen();
   RearrangeBooksOnScreen();
 end;
 end;
@@ -372,10 +416,11 @@ end;
 End;
 End;
 
 
 procedure Tform1.Formcreate(Sender: Tobject);
 procedure Tform1.Formcreate(Sender: Tobject);
-var 
+var
  i:integer;
  i:integer;
  cfgDir, cfgPath, dataDir: String;
  cfgDir, cfgPath, dataDir: String;
  ini: TIniFile;
  ini: TIniFile;
+ autoPdfCover: Boolean;
 begin
 begin
  bookWidth:=130;
  bookWidth:=130;
  bookHeight:=200;
  bookHeight:=200;
@@ -409,33 +454,37 @@ begin
  ButtonAdd.Picture:=mAdd;
  ButtonAdd.Picture:=mAdd;
  ButtonSettings.Picture:=mGear;
  ButtonSettings.Picture:=mGear;
 
 
- // Load config.ini if present to resolve dataPath and options
-  {$IFDEF MSWINDOWS}
-  cfgDir := GetEnvironmentVariable('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
-  {$ENDIF}
-  {$IFDEF UNIX}
-  cfgDir := GetEnvironmentVariable('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
-  {$ENDIF}
+ // Load config.ini if present to resolve paths and options
+  cfgDir := IncludeTrailingPathDelimiter(GetAppConfigDirUTF8(False));
   if not DirectoryExistsUTF8(cfgDir) then CreateDirUTF8(cfgDir);
   if not DirectoryExistsUTF8(cfgDir) then CreateDirUTF8(cfgDir);
 
 
   cfgPath := cfgDir + 'config.ini';
   cfgPath := cfgDir + 'config.ini';
   ini := TIniFile.Create(cfgPath);
   ini := TIniFile.Create(cfgPath);
   try
   try
-    dataDir := ini.ReadString('general', 'data_dir', cfgDir);
-    // You can also read autoPdfCover if you want it here:
-    // autoPdfCover := ini.ReadBool('general', 'auto_pdf_cover', True);
+    dataDir        := ini.ReadString('general', 'data_dir', cfgDir);
+    booksDir       := ini.ReadString('general', 'books_dir', cfgDir);
+    optCopyBooks   := ini.ReadBool('general', 'copy_books', True);
+    optRenameBooks := ini.ReadBool('general', 'rename_books', True);
+    optExtractMeta := ini.ReadBool('general', 'extract_metadata', True);
+    autoPdfCover   := ini.ReadBool('general','auto_pdf_cover', True);
   finally
   finally
     ini.Free;
     ini.Free;
   end;
   end;
 
 
   if not DirectoryExistsUTF8(dataDir) then CreateDirUTF8(dataDir);
   if not DirectoryExistsUTF8(dataDir) then CreateDirUTF8(dataDir);
+  if not DirectoryExistsUTF8(booksDir) then CreateDirUTF8(booksDir);
   dataPath := IncludeTrailingPathDelimiter(dataDir) + 'books.xml';
   dataPath := IncludeTrailingPathDelimiter(dataDir) + 'books.xml';
 
 
  BookList:=TBookCollection.Create;
  BookList:=TBookCollection.Create;
 
 
- if FileExistsUTF8(dataPath) then
-    LoadBooksXML(dataPath, BookList, PanelBackground);
-
+  // speed up startup: we skipped synchronous PDF generation during load
+  SetPdfCoverGenerationEnabled(False);
+  try
+    if FileExistsUTF8(dataPath) then
+      LoadBooksXML(dataPath, BookList, PanelBackground);
+  finally
+    SetPdfCoverGenerationEnabled(autoPdfCover); // re-enable per settings
+  end;
 
 
  for i:=0 to BookList.Count-1 do
  for i:=0 to BookList.Count-1 do
  begin
  begin
@@ -448,16 +497,6 @@ begin
   end;
   end;
  end;
  end;
 
 
- 
-  // speed up startup: we skipped synchronous PDF generation during LoadData
-  SetPdfCoverGenerationEnabled(False);
-  try
-    if FileExistsUTF8(dataPath) then
-      LoadBooksXML(dataPath, BookList, PanelBackground);
-  finally
-    SetPdfCoverGenerationEnabled(True); // re-enable for user actions
-  end;
-
  RearrangeBooksOnScreen();
  RearrangeBooksOnScreen();
 
 
  // Background: generate covers only where still generic
  // Background: generate covers only where still generic

+ 161 - 0
src/unitmetadata.pas

@@ -0,0 +1,161 @@
+unit unitMetadata;
+
+{$mode objfpc}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils;
+
+// Extract basic metadata (title, authors) from a book file.
+// Supports PDF (via pdfinfo) and EPUB (via unzip and parsing the OPF file).
+// Returns True if any metadata was found.
+function ExtractBookMetadata(const FileName: String; out Title, Authors: String): Boolean;
+
+implementation
+
+uses
+  Process, DOM, XMLRead, LazUTF8, StrUtils, LazFileUtils;
+
+function ExtractPDFMetadata(const FileName: String; out Title, Authors: String): Boolean;
+var
+  proc: TProcess;
+  sl: TStringList;
+  line: String;
+  i: Integer;
+  exe: String;
+begin
+  Result := False;
+  Title := '';
+  Authors := '';
+  exe := FindDefaultExecutablePath('pdfinfo');
+  if exe = '' then Exit;
+  proc := TProcess.Create(nil);
+  sl := TStringList.Create;
+  try
+    proc.Executable := exe;
+    proc.Parameters.Add(FileName);
+    proc.Options := [poWaitOnExit, poUsePipes];
+    proc.ShowWindow := swoHide;
+    proc.Execute;
+    sl.LoadFromStream(proc.Output);
+    for i := 0 to sl.Count - 1 do
+    begin
+      line := sl[i];
+      if (Title = '') and AnsiStartsStr('Title:', line) then
+        Title := Trim(Copy(line, 7, MaxInt));
+      if (Authors = '') and (AnsiStartsStr('Author:', line) or AnsiStartsStr('Authors:', line)) then
+        Authors := Trim(Copy(line, Pos(':', line) + 1, MaxInt));
+    end;
+    Result := (Title <> '') or (Authors <> '');
+  finally
+    sl.Free;
+    proc.Free;
+  end;
+end;
+
+function ExtractEPUBMetadata(const FileName: String; out Title, Authors: String): Boolean;
+var
+  proc: TProcess;
+  sl: TStringList;
+  exe, opfPath, line: String;
+  xml: TXMLDocument;
+  stream: TStringStream;
+  meta, node: TDOMNode;
+  i: Integer;
+  lname: String;
+begin
+  Result := False;
+  Title := '';
+  Authors := '';
+  exe := FindDefaultExecutablePath('unzip');
+  if exe = '' then Exit;
+  // list files
+  proc := TProcess.Create(nil);
+  sl := TStringList.Create;
+  try
+    proc.Executable := exe;
+    proc.Parameters.Add('-Z1');
+    proc.Parameters.Add(FileName);
+    proc.Options := [poWaitOnExit, poUsePipes];
+    proc.ShowWindow := swoHide;
+    proc.Execute;
+    sl.LoadFromStream(proc.Output);
+    opfPath := '';
+    for i := 0 to sl.Count - 1 do
+    begin
+      line := Trim(sl[i]);
+      if LowerCase(ExtractFileExt(line)) = '.opf' then
+      begin
+        opfPath := line;
+        Break;
+      end;
+    end;
+  finally
+    sl.Free;
+    proc.Free;
+  end;
+  if opfPath = '' then Exit;
+  // extract opf content
+  proc := TProcess.Create(nil);
+  stream := TStringStream.Create('');
+  try
+    proc.Executable := exe;
+    proc.Parameters.Add('-p');
+    proc.Parameters.Add(FileName);
+    proc.Parameters.Add(opfPath);
+    proc.Options := [poWaitOnExit, poUsePipes];
+    proc.ShowWindow := swoHide;
+    proc.Execute;
+    stream.CopyFrom(proc.Output, 0);
+    stream.Position := 0;
+  finally
+    proc.Free;
+  end;
+  try
+    ReadXMLFile(xml, stream);
+    try
+      meta := xml.DocumentElement.FindNode('metadata');
+      if meta <> nil then
+      begin
+        for i := 0 to meta.ChildNodes.Count - 1 do
+        begin
+          node := meta.ChildNodes[i];
+          lname := UTF8LowerCase(node.NodeName);
+          if (Title = '') and ((lname = 'dc:title') or (lname = 'title')) then
+            Title := Trim(node.TextContent);
+          if ((lname = 'dc:creator') or (lname = 'creator') or (lname = 'dc:author') or (lname = 'author')) then
+          begin
+            if Authors <> '' then Authors := Authors + ', ';
+            Authors := Authors + Trim(node.TextContent);
+          end;
+        end;
+      end;
+    finally
+      xml.Free;
+    end;
+  finally
+    stream.Free;
+  end;
+  Result := (Title <> '') or (Authors <> '');
+end;
+
+function ExtractBookMetadata(const FileName: String; out Title, Authors: String): Boolean;
+var
+  ext: String;
+begin
+  ext := LowerCase(ExtractFileExt(FileName));
+  if ext = '.pdf' then
+    Result := ExtractPDFMetadata(FileName, Title, Authors)
+  else if ext = '.epub' then
+    Result := ExtractEPUBMetadata(FileName, Title, Authors)
+  else
+  begin
+    Title := '';
+    Authors := '';
+    Result := False;
+  end;
+end;
+
+end.
+

+ 52 - 11
src/unitsettingsdialog.pas

@@ -14,11 +14,18 @@ type
     lblDataDir: TLabel;
     lblDataDir: TLabel;
     edtDataDir: TEdit;
     edtDataDir: TEdit;
     btnBrowse: TButton;
     btnBrowse: TButton;
+    lblBooksDir: TLabel;
+    edtBooksDir: TEdit;
+    btnBrowseBooks: TButton;
+    chkCopyBooks: TCheckBox;
+    chkRenameBooks: TCheckBox;
+    chkMeta: TCheckBox;
     chkPdfCovers: TCheckBox;
     chkPdfCovers: TCheckBox;
     btnOK: TBitBtn;
     btnOK: TBitBtn;
     btnCancel: TBitBtn;
     btnCancel: TBitBtn;
     procedure FormCreate(Sender: TObject);
     procedure FormCreate(Sender: TObject);
     procedure BtnBrowseClick(Sender: TObject);
     procedure BtnBrowseClick(Sender: TObject);
+    procedure BtnBrowseBooksClick(Sender: TObject);
     procedure BtnOKClick(Sender: TObject);
     procedure BtnOKClick(Sender: TObject);
     function  ConfigDir: String;
     function  ConfigDir: String;
     function  ConfigPath: String;
     function  ConfigPath: String;
@@ -46,10 +53,10 @@ begin
   Caption := 'Settings';
   Caption := 'Settings';
   BorderStyle := bsDialog;
   BorderStyle := bsDialog;
   Position := poScreenCenter;
   Position := poScreenCenter;
-  ClientWidth := 520; ClientHeight := 180;
+  ClientWidth := 520; ClientHeight := 300;
 
 
   lblDataDir := TLabel.Create(Self); lblDataDir.Parent := Self;
   lblDataDir := TLabel.Create(Self); lblDataDir.Parent := Self;
-  lblDataDir.Caption := 'Data folder (where data.dat is saved):';
+  lblDataDir.Caption := 'Data folder (where books.xml is saved):';
   lblDataDir.Left := 16; lblDataDir.Top := 16;
   lblDataDir.Left := 16; lblDataDir.Top := 16;
 
 
   edtDataDir := TEdit.Create(Self); edtDataDir.Parent := Self;
   edtDataDir := TEdit.Create(Self); edtDataDir.Parent := Self;
@@ -59,16 +66,39 @@ begin
   btnBrowse.Left := 436; btnBrowse.Top := 38; btnBrowse.Caption := 'Browse...';
   btnBrowse.Left := 436; btnBrowse.Top := 38; btnBrowse.Caption := 'Browse...';
   btnBrowse.OnClick := @BtnBrowseClick;
   btnBrowse.OnClick := @BtnBrowseClick;
 
 
+  lblBooksDir := TLabel.Create(Self); lblBooksDir.Parent := Self;
+  lblBooksDir.Caption := 'Managed books folder:';
+  lblBooksDir.Left := 16; lblBooksDir.Top := 80;
+
+  edtBooksDir := TEdit.Create(Self); edtBooksDir.Parent := Self;
+  edtBooksDir.Left := 16; edtBooksDir.Top := 104; edtBooksDir.Width := 410;
+
+  btnBrowseBooks := TButton.Create(Self); btnBrowseBooks.Parent := Self;
+  btnBrowseBooks.Left := 436; btnBrowseBooks.Top := 102; btnBrowseBooks.Caption := 'Browse...';
+  btnBrowseBooks.OnClick := @BtnBrowseBooksClick;
+
+  chkCopyBooks := TCheckBox.Create(Self); chkCopyBooks.Parent := Self;
+  chkCopyBooks.Caption := 'Copy books to managed folder on import';
+  chkCopyBooks.Left := 16; chkCopyBooks.Top := 144;
+
+  chkRenameBooks := TCheckBox.Create(Self); chkRenameBooks.Parent := Self;
+  chkRenameBooks.Caption := 'Rename books based on metadata';
+  chkRenameBooks.Left := 16; chkRenameBooks.Top := 168;
+
+  chkMeta := TCheckBox.Create(Self); chkMeta.Parent := Self;
+  chkMeta.Caption := 'Extract metadata from book files';
+  chkMeta.Left := 16; chkMeta.Top := 192;
+
   chkPdfCovers := TCheckBox.Create(Self); chkPdfCovers.Parent := Self;
   chkPdfCovers := TCheckBox.Create(Self); chkPdfCovers.Parent := Self;
   chkPdfCovers.Caption := 'Auto-extract PDF cover on import (requires pdftoppm)';
   chkPdfCovers.Caption := 'Auto-extract PDF cover on import (requires pdftoppm)';
-  chkPdfCovers.Left := 16; chkPdfCovers.Top := 80;
+  chkPdfCovers.Left := 16; chkPdfCovers.Top := 216;
 
 
   btnOK := TBitBtn.Create(Self); btnOK.Parent := Self;
   btnOK := TBitBtn.Create(Self); btnOK.Parent := Self;
-  btnOK.Kind := bkOK; btnOK.Left := ClientWidth - 180; btnOK.Top := 130;
+  btnOK.Kind := bkOK; btnOK.Left := ClientWidth - 180; btnOK.Top := 248;
   btnOK.OnClick := @BtnOKClick;
   btnOK.OnClick := @BtnOKClick;
 
 
   btnCancel := TBitBtn.Create(Self); btnCancel.Parent := Self;
   btnCancel := TBitBtn.Create(Self); btnCancel.Parent := Self;
-  btnCancel.Kind := bkCancel; btnCancel.Left := ClientWidth - 92; btnCancel.Top := 130;
+  btnCancel.Kind := bkCancel; btnCancel.Left := ClientWidth - 92; btnCancel.Top := 248;
 
 
   LoadSettings;
   LoadSettings;
 end;
 end;
@@ -81,6 +111,14 @@ begin
     edtDataDir.Text := dir;
     edtDataDir.Text := dir;
 end;
 end;
 
 
+procedure TSettingsDialog.BtnBrowseBooksClick(Sender: TObject);
+var dir: String;
+begin
+  dir := edtBooksDir.Text;
+  if SelectDirectory('Choose books folder', '', dir) then
+    edtBooksDir.Text := dir;
+end;
+
 procedure TSettingsDialog.BtnOKClick(Sender: TObject);
 procedure TSettingsDialog.BtnOKClick(Sender: TObject);
 begin
 begin
   SaveSettings;
   SaveSettings;
@@ -89,12 +127,7 @@ end;
 
 
 function TSettingsDialog.ConfigDir: String;
 function TSettingsDialog.ConfigDir: String;
 begin
 begin
-  {$IFDEF MSWINDOWS}
-  Result := GetEnvironmentVariable('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
-  {$ENDIF}
-  {$IFDEF UNIX}
-  Result := GetEnvironmentVariable('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
-  {$ENDIF}
+  Result := IncludeTrailingPathDelimiter(GetAppConfigDirUTF8(False));
   if not DirectoryExistsUTF8(Result) then CreateDirUTF8(Result);
   if not DirectoryExistsUTF8(Result) then CreateDirUTF8(Result);
 end;
 end;
 
 
@@ -109,6 +142,10 @@ begin
   ini := TIniFile.Create(ConfigPath);
   ini := TIniFile.Create(ConfigPath);
   try
   try
     edtDataDir.Text := ini.ReadString('general', 'data_dir', '');
     edtDataDir.Text := ini.ReadString('general', 'data_dir', '');
+    edtBooksDir.Text := ini.ReadString('general', 'books_dir', edtDataDir.Text);
+    chkCopyBooks.Checked := ini.ReadBool('general', 'copy_books', True);
+    chkRenameBooks.Checked := ini.ReadBool('general', 'rename_books', True);
+    chkMeta.Checked := ini.ReadBool('general', 'extract_metadata', True);
     chkPdfCovers.Checked := ini.ReadBool('general', 'auto_pdf_cover', True);
     chkPdfCovers.Checked := ini.ReadBool('general', 'auto_pdf_cover', True);
   finally
   finally
     ini.Free;
     ini.Free;
@@ -121,6 +158,10 @@ begin
   ini := TIniFile.Create(ConfigPath);
   ini := TIniFile.Create(ConfigPath);
   try
   try
     ini.WriteString('general', 'data_dir', edtDataDir.Text);
     ini.WriteString('general', 'data_dir', edtDataDir.Text);
+    ini.WriteString('general', 'books_dir', edtBooksDir.Text);
+    ini.WriteBool('general', 'copy_books', chkCopyBooks.Checked);
+    ini.WriteBool('general', 'rename_books', chkRenameBooks.Checked);
+    ini.WriteBool('general', 'extract_metadata', chkMeta.Checked);
     ini.WriteBool('general', 'auto_pdf_cover', chkPdfCovers.Checked);
     ini.WriteBool('general', 'auto_pdf_cover', chkPdfCovers.Checked);
   finally
   finally
     ini.Free;
     ini.Free;