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

Merge pull request #17 from bemagri/codex/implement-organized-book-inclusion-feature

Add managed book storage and metadata extraction
Bernardo Magri 4 месяцев назад
Родитель
Сommit
35c36a3c3a
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
   Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls, LazFileUtils,
   Book, BookCollection, LCLIntf, LResources, StdCtrls, LCLType, IniFiles, unitSettingsDialog,
-  unitCoverWorker, unitStorageXML;
+  unitCoverWorker, unitStorageXML, unitMetadata;
 
 
 type
@@ -56,8 +56,10 @@ var
   BookList:TBookCollection;
   Xspace, Yspace:integer;
   dataPath:String;
+  booksDir:String;
   background,toolbar:TPicture;
   bookWidth,bookHeight:Integer;
+  optCopyBooks,optRenameBooks,optExtractMeta:Boolean;
 
 
 
@@ -302,21 +304,63 @@ procedure Tform1.ButtonAddClick(Sender: TObject);
 var
   book:TBook;
   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
 
 if OpenDialog1.Execute then
 begin
   for i:= 0 to Opendialog1.Files.Count-1 do
   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;
-  CoverWorkerEnqueueBookIfMissing(Book);
   CoverWorkerStart;
   RearrangeBooksOnScreen();
 end;
@@ -372,10 +416,11 @@ end;
 End;
 
 procedure Tform1.Formcreate(Sender: Tobject);
-var 
+var
  i:integer;
  cfgDir, cfgPath, dataDir: String;
  ini: TIniFile;
+ autoPdfCover: Boolean;
 begin
  bookWidth:=130;
  bookHeight:=200;
@@ -409,33 +454,37 @@ begin
  ButtonAdd.Picture:=mAdd;
  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);
 
   cfgPath := cfgDir + 'config.ini';
   ini := TIniFile.Create(cfgPath);
   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
     ini.Free;
   end;
 
   if not DirectoryExistsUTF8(dataDir) then CreateDirUTF8(dataDir);
+  if not DirectoryExistsUTF8(booksDir) then CreateDirUTF8(booksDir);
   dataPath := IncludeTrailingPathDelimiter(dataDir) + 'books.xml';
 
  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
  begin
@@ -448,16 +497,6 @@ begin
   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();
 
  // 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;
     edtDataDir: TEdit;
     btnBrowse: TButton;
+    lblBooksDir: TLabel;
+    edtBooksDir: TEdit;
+    btnBrowseBooks: TButton;
+    chkCopyBooks: TCheckBox;
+    chkRenameBooks: TCheckBox;
+    chkMeta: TCheckBox;
     chkPdfCovers: TCheckBox;
     btnOK: TBitBtn;
     btnCancel: TBitBtn;
     procedure FormCreate(Sender: TObject);
     procedure BtnBrowseClick(Sender: TObject);
+    procedure BtnBrowseBooksClick(Sender: TObject);
     procedure BtnOKClick(Sender: TObject);
     function  ConfigDir: String;
     function  ConfigPath: String;
@@ -46,10 +53,10 @@ begin
   Caption := 'Settings';
   BorderStyle := bsDialog;
   Position := poScreenCenter;
-  ClientWidth := 520; ClientHeight := 180;
+  ClientWidth := 520; ClientHeight := 300;
 
   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;
 
   edtDataDir := TEdit.Create(Self); edtDataDir.Parent := Self;
@@ -59,16 +66,39 @@ begin
   btnBrowse.Left := 436; btnBrowse.Top := 38; btnBrowse.Caption := 'Browse...';
   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.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.Kind := bkOK; btnOK.Left := ClientWidth - 180; btnOK.Top := 130;
+  btnOK.Kind := bkOK; btnOK.Left := ClientWidth - 180; btnOK.Top := 248;
   btnOK.OnClick := @BtnOKClick;
 
   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;
 end;
@@ -81,6 +111,14 @@ begin
     edtDataDir.Text := dir;
 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);
 begin
   SaveSettings;
@@ -89,12 +127,7 @@ end;
 
 function TSettingsDialog.ConfigDir: String;
 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);
 end;
 
@@ -109,6 +142,10 @@ begin
   ini := TIniFile.Create(ConfigPath);
   try
     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);
   finally
     ini.Free;
@@ -121,6 +158,10 @@ begin
   ini := TIniFile.Create(ConfigPath);
   try
     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);
   finally
     ini.Free;