Bernardo Magri před 4 měsíci
rodič
revize
1e487cd429
12 změnil soubory, kde provedl 1115 přidání a 192 odebrání
  1. 60 0
      flake.lock
  2. 82 51
      flake.nix
  3. 232 95
      src/book.pas
  4. 6 0
      src/bookcollection.pas
  5. 163 29
      src/main.pas
  6. 14 11
      src/myBookShelf.lpr
  7. binární
      src/myBookShelf.res
  8. 206 0
      src/unitCoverWorker.pas
  9. 174 0
      src/unitStorageXML.pas
  10. 2 2
      src/unitbookdialog.lfm
  11. 173 1
      src/unitbookdialog.pas
  12. 3 3
      src/unitsettingsdialog.pas

+ 60 - 0
flake.lock

@@ -0,0 +1,60 @@
+{
+  "nodes": {
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1731533236,
+        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1756416120,
+        "narHash": "sha256-o7mrT363vghhfjsO+wvHvrTqjzCwUPsV8Z+qbyeEENg=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "68a18fd314734f7441842e9e2e1b9f92186838f6",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      }
+    },
+    "systems": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}

+ 82 - 51
flake.nix

@@ -2,7 +2,7 @@
   description = "Dev shell for myBookShelf (Lazarus/FPC)";
 
   inputs = {
-    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; # feel free to bump to unstable if you prefer
+    nixpkgs.url = "github:NixOS/nixpkgs";
     flake-utils.url = "github:numtide/flake-utils";
   };
 
@@ -10,67 +10,98 @@
     flake-utils.lib.eachDefaultSystem (system:
       let
         pkgs = import nixpkgs { inherit system; };
+        lib  = pkgs.lib;
+
+        lazGtk =
+          if pkgs ? lazarus-gtk2 then pkgs.lazarus-gtk2
+          else if pkgs ? lazarus then pkgs.lazarus
+          else pkgs.lazarus-qt5;
+
+        lazQt5 =
+          if pkgs ? lazarus-qt5 then pkgs.lazarus-qt5
+          else if pkgs ? lazarus then pkgs.lazarus
+          else lazGtk;
+
+        fpcSrcOpt = if pkgs ? fpcSrc then pkgs.fpcSrc else null;
+
+        gtkLibPath = lib.makeLibraryPath [
+          pkgs.gtk2 pkgs.glib pkgs.pango pkgs.cairo pkgs.gdk-pixbuf pkgs.atk
+          pkgs.xorg.libX11 pkgs.xorg.libXext pkgs.xorg.libXrender pkgs.xorg.libXrandr
+          pkgs.xorg.libXinerama pkgs.xorg.libXcursor pkgs.xorg.libXi pkgs.xorg.libXfixes
+        ];
+        qtLibPath  = lib.makeLibraryPath [ pkgs.qt5.qtbase pkgs.qt5.qtsvg pkgs.qt5.qtx11extras ];
+        qtPlugins  = "${pkgs.qt5.qtbase}/lib/qt-5/plugins";
+
+        commonInputs = [
+          pkgs.fpc
+          lazGtk
+          pkgs.poppler_utils
+          pkgs.openssl
+          pkgs.cacert
+          pkgs.gdb
+          pkgs.pkg-config
+          pkgs.gtk2
+          pkgs.libqt5pas
+        ] ++ lib.optionals (fpcSrcOpt != null) [ fpcSrcOpt ];
+
+        commonHook = ''
+          : "${LCLWidgetType:=gtk2}"
+          export LCLWidgetType
+          export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
+          export NIX_SSL_CERT_FILE=$SSL_CERT_FILE
+
+          # Find Lazarus IDE binary and expose a 'lazarus' helper
+          LAZ_BIN=""
+          for b in lazarus lazarus-ide startlazarus; do
+            if command -v "$b" >/dev/null 2>&1; then LAZ_BIN="$b"; break; fi
+          done
+          lazarus () { "$LAZ_BIN" "$@"; }
+          export -f lazarus
+
+          # Prefer pkgs.fpcSrc if available; else probe inside pkgs.fpc for sources
+          if [ -d "${if fpcSrcOpt != null then fpcSrcOpt else ""}" ]; then
+            export FPCSRC="${if fpcSrcOpt != null then fpcSrcOpt else ""}"
+          else
+            FPC_OUT=${pkgs.fpc}
+            for cand in "$FPC_OUT/share/fpcsrc" "$FPC_OUT/lib/fpc"/*/source "$FPC_OUT/share/fpc/source"; do
+              [ -d "$cand" ] && export FPCSRC="$cand" && break
+            done
+          fi
+
+          # Help the linker find libQt5Pas for Qt builds
+          export FPCOPT="$FPCOPT -Fl${pkgs.libqt5pas}/lib"
+
+          echo
+          echo "myBookShelf dev shell"
+          echo "  • Widgetset:      $LCLWidgetType"
+          [ -n "$LAZ_BIN" ] && echo "  • Lazarus:        $LAZ_BIN   (helper: 'lazarus')"
+          [ -n "$FPCSRC" ] && echo "  • FPC source dir: $FPCSRC" || echo "  • FPC sources:    not found (OK for building)"
+          echo "Commands:"
+          echo "  lazbuild --ws=\$LCLWidgetType src/myBookShelf.lpi"
+          echo "  lazarus &"
+          echo
+        '';
       in {
         devShells = {
-          # Safe default: GTK2 widgetset
           default = pkgs.mkShell {
-            buildInputs = [
-              pkgs.fpc
-              pkgs.fpcSrc
-              pkgs.lazarus
-              pkgs.gtk2                 # widget toolkit for LCL
-              pkgs.poppler_utils        # pdftoppm for PDF covers
-              pkgs.openssl
-              pkgs.cacert               # SSL certs for HTTPS
-              pkgs.gdb                  # Lazarus debugger
-              pkgs.pkg-config
-            ];
+            buildInputs = commonInputs;
             shellHook = ''
-              export LCLWidgetType=gtk2
-              export FPCSRC=${pkgs.fpcSrc}
-              # Nix usually sets this, but make it explicit:
-              export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
-              export NIX_SSL_CERT_FILE=$SSL_CERT_FILE
-
-              echo
-              echo "myBookShelf dev shell (GTK2) ready!"
-              echo "  • Widgetset:      $LCLWidgetType"
-              echo "  • FPC source dir: $FPCSRC"
-              echo "Commands:"
-              echo "  lazarus &     # launch IDE (first run: point FPC sources to \$FPCSRC if asked)"
-              echo "  lazbuild --ws=$LCLWidgetType path/to/project.lpi"
-              echo
+              # GTK2 runtime libs so the app runs *inside* the shell
+              export LD_LIBRARY_PATH="${gtkLibPath}:$LD_LIBRARY_PATH"
+              export LD_LIBRARY_PATH="${gtkLibPath}:${pkgs.openssl.out}/lib:$LD_LIBRARY_PATH"
+${commonHook}
             '';
           };
 
-          # Optional: Qt5 widgetset
           qt5 = pkgs.mkShell {
-            buildInputs = [
-              pkgs.fpc
-              pkgs.fpcSrc
-              pkgs.lazarus
-              pkgs.qt5.qtbase
-              pkgs.qt5.qttools
-              pkgs.poppler_utils
-              pkgs.openssl
-              pkgs.cacert
-              pkgs.gdb
-              pkgs.pkg-config
-            ];
+            buildInputs = commonInputs ++ [ lazQt5 pkgs.qt5.qtbase pkgs.qt5.qttools ];
             shellHook = ''
               export LCLWidgetType=qt5
-              export FPCSRC=${pkgs.fpcSrc}
-              export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
-              export NIX_SSL_CERT_FILE=$SSL_CERT_FILE
+              # Qt runtime libs + plugin search path
+              export LD_LIBRARY_PATH="${qtLibPath}:${pkgs.libqt5pas}/lib:${pkgs.openssl.out}/lib:$LD_LIBRARY_PATH"
+              export QT_PLUGIN_PATH="${qtPlugins}"
 
-              echo
-              echo "myBookShelf dev shell (Qt5) ready!"
-              echo "  • Widgetset:      $LCLWidgetType"
-              echo "  • FPC source dir: $FPCSRC"
-              echo "Commands:"
-              echo "  lazarus &     # launch IDE"
-              echo "  lazbuild --ws=$LCLWidgetType path/to/project.lpi"
-              echo
+${commonHook}
             '';
           };
         };

+ 232 - 95
src/book.pas

@@ -5,130 +5,267 @@ unit Book;
 interface
 
 uses
-  Classes, Sysutils, Graphics, ExtCtrls, LCLIntf, Controls;
+  Classes, SysUtils, Graphics, ExtCtrls, Controls, LCLIntf, LResources, Process,
+  Math, FileUtil;
 
-type
 
+type
   { TBook }
-
   TBook = class(TObject)
-    private
-      mTitle : String;
-      mAuthors:String;
-      mISBN:String;
-      mFilePath : String;
-      mCover : TImage;
-      mImagePath : String;
-      mIsSelected: Boolean;
-      procedure Setfile(Avalue: String);
-      procedure Setimage(Avalue: String);
-
-    public
-      constructor Create(parent:TComponent);
-      procedure Bookmousedown(Sender: Tobject; Button: Tmousebutton;
-        Shift: Tshiftstate; X, Y: Integer);
-      property Cover : TImage read mCover;
-      property ImagePath : String read mImagePath write SetImage;
-      property FilePath : String read mFilePath write SetFile;
-      property isSelected : Boolean read mIsSelected write mIsSelected;
-      property Title : String read mTitle write mTitle;
-      property Authors : String read mAuthors write mAuthors;
-      property ISBN : String read mISBN write mISBN;
-      destructor Destroy; override;
-      procedure BookDoubleClick(Sender:TObject);
-      procedure BookCoverPaint(Sender:TObject);
+  private
+    mTitle      : String;
+    mAuthors    : String;
+    mISBN       : String;
+    mFilePath   : String;
+    mImagePath  : String;     // original image path (or '')
+    mCover      : TImage;
+    mIsSelected : Boolean;
+    mScaledW    : Integer;    // last pre-scale width we rendered for
+    mScaledH    : Integer;    // last pre-scale height we rendered for
+
+    procedure SetFile(AValue: String);
+    procedure SetImage(AValue: String);
+    function  TryGenerateCoverFromPDF(const PdfPath: String): String;
+
+  public
+    constructor Create(Parent: TComponent);
+    destructor Destroy; override;
+
+    procedure BookMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
+    procedure BookDoubleClick(Sender: TObject);
+    procedure BookCoverPaint(Sender: TObject);
+
+    // Call this after you change mCover.Width/Height (layout/resizes)
+    procedure EnsureScaledToCoverSize;
+
+    property Title     : String  read mTitle     write mTitle;
+    property Authors   : String  read mAuthors   write mAuthors;
+    property ISBN      : String  read mISBN      write mISBN;
+    property FilePath  : String  read mFilePath  write SetFile;
+    property ImagePath : String  read mImagePath write SetImage;
+    property Cover     : TImage  read mCover;
+    property IsSelected: Boolean read mIsSelected write mIsSelected;
   end;
 
+// Allow main code to temporarily disable PDF cover extraction (e.g., during startup load)
+procedure SetPdfCoverGenerationEnabled(AEnabled: Boolean);
+
 implementation
 
-uses UnitBookDialog;
+var
+  gPdfCoverEnabled: Boolean = True;
 
-{ TBook }
+procedure SetPdfCoverGenerationEnabled(AEnabled: Boolean);
+begin
+  gPdfCoverEnabled := AEnabled;
+end;
 
-procedure Tbook.Setimage(Avalue: String);
+{------------------------------------------------------------------------------}
+{ Helper: try to render first page of a PDF into a JPEG using Poppler         }
+{------------------------------------------------------------------------------}
+function TBook.TryGenerateCoverFromPDF(const PdfPath: String): String;
+var
+  Proc: TProcess;
+  OutBase, Converter: String;
 begin
-  mImagePath:=AValue;
-  if not FileExists(Avalue) then
-     mCover.Picture.LoadFromLazarusResource('generic_cover')
-  else
-  begin
-     mImagePath:=AValue;
-     mCover.Picture.LoadFromFile(mImagePath);
+  Result := '';
+  if not gPdfCoverEnabled then Exit;
+
+  // look for pdftoppm in PATH (Poppler utilities)
+  Converter := FindDefaultExecutablePath('pdftoppm');
+  if Converter = '' then
+    Exit; // tool not available, keep default behavior
+
+  OutBase := ChangeFileExt(PdfPath, '');  // e.g., /path/book.pdf -> /path/book
+
+  Proc := TProcess.Create(nil);
+  try
+    Proc.Executable := Converter;
+    // pdftoppm -jpeg -singlefile -f 1 -l 1 <pdf> <out_base>
+    Proc.Parameters.Add('-jpeg');
+    Proc.Parameters.Add('-singlefile');
+    Proc.Parameters.Add('-f'); Proc.Parameters.Add('1');
+    Proc.Parameters.Add('-l'); Proc.Parameters.Add('1');
+    Proc.Parameters.Add(PdfPath);
+    Proc.Parameters.Add(OutBase);
+    Proc.Options := [poWaitOnExit];
+    Proc.ShowWindow := swoHIDE;
+    Proc.Execute;
+  finally
+    Proc.Free;
   end;
-End;
 
-procedure Tbook.Setfile(Avalue: String);
+  if FileExists(OutBase + '.jpg') then
+    Result := OutBase + '.jpg';
+end;
+
+{------------------------------------------------------------------------------}
+{ Basic painting: selection outline                                            }
+{------------------------------------------------------------------------------}
+procedure TBook.BookCoverPaint(Sender: TObject);
 begin
-  if Mfilepath=Avalue then Exit;
-  Mfilepath:=Avalue;
-  SetImage(ChangeFileExt(Avalue, '.png'));
-  SetImage(ChangeFileExt(Avalue, '.jpg'));
+  if mIsSelected then
+  begin
+    mCover.Canvas.Brush.Style := bsClear;
+    mCover.Canvas.Pen.Width   := 4;
+    mCover.Canvas.Pen.Color   := clRed;
+    mCover.Canvas.RoundRect(1, 1, mCover.Width - 1, mCover.Height - 1, 10, 10);
+  end;
 end;
 
-constructor Tbook.Create(Parent: Tcomponent);
-var pic:TPicture;
+{------------------------------------------------------------------------------}
+{ Mouse handlers (hook up in constructor)                                      }
+{------------------------------------------------------------------------------}
+procedure TBook.BookMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
 begin
-  mCover:=TImage.Create(parent);
-  pic:=TPicture.Create;
-  mCover.Picture:=pic;
-  mCover.Stretch:=true;
-  mCover.OnDblClick:=@BookDoubleClick;
-  mCover.OnPaint:=@BookCoverPaint;
-  mCover.OnMouseDown:=@Bookmousedown;
-  mIsSelected:=False;
-  mTitle:='';
-  mAuthors:='';
-  mISBN:='';
-  mImagePath:='';
+  // You likely toggle selection elsewhere; keep this stub or wire to a callback
 end;
 
-procedure Tbook.Bookmousedown(Sender: Tobject; Button: Tmousebutton;
-  Shift: Tshiftstate; X, Y: Integer);
-var  dialog:TBookEditDialog;
+procedure TBook.BookDoubleClick(Sender: TObject);
 begin
-  if Button = TMouseButton.mbRight then
-     begin
-        mIsSelected:=True;
-        mCover.Repaint;
-        dialog:= TBookEditDialog.Create(nil);
-        dialog.LoadBook(Self);
-        dialog.ShowModal;
-        mIsSelected:=False;
-        mCover.Repaint;
-     end;
-  if Button = TMouseButton.mbLeft then
-     begin
-        mIsSelected:= not mIsSelected;
-        mCover.BeginDrag(True);
-        mCover.Repaint;
-     end;
-End;
-
-
-destructor Tbook.Destroy;
+  // Open file / details dialog etc. (your existing logic)
+end;
+
+{------------------------------------------------------------------------------}
+{ Pre-scale loader: draw once into a bitmap matching the control size          }
+{------------------------------------------------------------------------------}
+procedure TBook.SetImage(AValue: String);
+var
+  Pic : TPicture;
+  Bmp : TBitmap;
+  dstW, dstH, offX, offY: Integer;
+  scale: Double;
+  W, H: Integer;
 begin
-  FreeAndNil(mCover);
+  // Default state
+  mImagePath := '';
+  mScaledW := 0; mScaledH := 0;
+
+  // Ensure we have a sensible target size (layout usually sets this)
+  W := mCover.Width;  H := mCover.Height;
+  if (W <= 0) or (H <= 0) then
+  begin
+    // fallback: honor the common 130x250 default
+    W := 130; H := 250;
+    mCover.Width := W; mCover.Height := H;
+  end;
+
+  if (AValue <> '') and FileExists(AValue) then
+  begin
+    Pic := TPicture.Create;
+    Bmp := TBitmap.Create;
+    try
+      Pic.LoadFromFile(AValue);
+
+      Bmp.SetSize(W, H);
+      // letterbox background
+      Bmp.Canvas.Brush.Color := clBtnFace;
+      Bmp.Canvas.FillRect(0, 0, W, H);
+
+      if (Pic.Width > 0) and (Pic.Height > 0) then
+      begin
+        scale := Min(W / Pic.Width, H / Pic.Height);
+        if scale > 1 then scale := 1; // avoid upscale
+        dstW := Round(Pic.Width * scale);
+        dstH := Round(Pic.Height * scale);
+        offX := (W - dstW) div 2;
+        offY := (H - dstH) div 2;
+        Bmp.Canvas.StretchDraw(Rect(offX, offY, offX + dstW, offY + dstH), Pic.Graphic);
+      end;
+
+      // No runtime scaling anymore; we drew at target size
+      mCover.Stretch := False;
+      mCover.Center  := False;
+      mCover.AutoSize:= False;
+
+      mCover.Picture.Assign(Bmp);
+      mImagePath := AValue;
+      mScaledW := W; mScaledH := H;
+      Exit;
+    except
+      // fall through to generic on any failure
+    end;
+    Pic.Free;
+    Bmp.Free;
+  end;
+
+  // Generic fallback
+  mCover.Stretch := True;
+  mCover.Picture.LoadFromLazarusResource('generic_cover');
 end;
 
+{------------------------------------------------------------------------------}
+{ EnsureScaledToCoverSize: re-render if size changed since last pre-scale      }
+{------------------------------------------------------------------------------}
+procedure TBook.EnsureScaledToCoverSize;
+begin
+  if (mImagePath <> '') and ((mScaledW <> mCover.Width) or (mScaledH <> mCover.Height)) then
+    SetImage(mImagePath);
+end;
 
-procedure Tbook.Bookdoubleclick(Sender: Tobject);
+{------------------------------------------------------------------------------}
+{ File setter: try sibling .png/.jpg, then PDF first-page render if needed     }
+{------------------------------------------------------------------------------}
+procedure TBook.SetFile(AValue: String);
+var
+  ext, gen: String;
 begin
-  mIsSelected:=True;
-  mCover.Repaint;
-  OpenDocument(mFilePath);
+  if mFilePath = AValue then Exit;
+  mFilePath := AValue;
+
+  // first try sibling images
+  SetImage(ChangeFileExt(AValue, '.png'));
+  if mImagePath = '' then
+    SetImage(ChangeFileExt(AValue, '.jpg'));
+
+  // if still no image and it's a PDF, try to generate one
+  ext := LowerCase(ExtractFileExt(AValue));
+  if (mImagePath = '') and (ext = '.pdf') then
+  begin
+    gen := TryGenerateCoverFromPDF(AValue);
+    if gen <> '' then
+      SetImage(gen);
+  end;
 end;
 
-procedure Tbook.Bookcoverpaint(Sender: Tobject);
+{------------------------------------------------------------------------------}
+{ Lifecycle                                                                     }
+{------------------------------------------------------------------------------}
+constructor TBook.Create(Parent: TComponent);
 begin
-  if mIsSelected then
-    begin
-    mCover.Canvas.Brush.Style:=bsClear;
-    mCover.Canvas.Pen.Width:=4;
-    mCover.Canvas.Pen.Color:=clRed;
-    mCover.Canvas.Rectangle(1,1,mCover.Width,mCover.Height);
-    end;
+  inherited Create;
+  mTitle      := '';
+  mAuthors    := '';
+  mISBN       := '';
+  mFilePath   := '';
+  mImagePath  := '';
+  mIsSelected := False;
+  mScaledW    := 0;
+  mScaledH    := 0;
+
+  mCover := TImage.Create(Parent);
+  if Parent is TWinControl then
+    mCover.Parent := TWinControl(Parent);
+
+  // Desired default control size
+  mCover.Width  := 130;
+  mCover.Height := 250;
+
+  // Interactions & visuals
+  mCover.Stretch   := True;
+  mCover.OnPaint   := @BookCoverPaint;
+  mCover.OnMouseDown := @BookMouseDown;
+  mCover.OnDblClick  := @BookDoubleClick;
+  mCover.Cursor    := crHandPoint;
+
+  // default image
+  mCover.Picture.LoadFromLazarusResource('generic_cover');
 end;
 
+destructor TBook.Destroy;
+begin
+  FreeAndNil(mCover);
+  inherited Destroy;
+end;
 
 end.
-

+ 6 - 0
src/bookcollection.pas

@@ -23,6 +23,7 @@ TBookCollection = class(TObject)
     property  Books[Index: Integer]:TBook read Get;
     procedure Remove(book:TBook);
     function Count:Integer;
+    procedure Clear;
     procedure SwapBooks(Source,Dest:Integer);
     constructor Create;
     destructor Destroy; override;
@@ -33,6 +34,11 @@ implementation
 
 { TBookCollection }
 
+procedure TBookCollection.Clear;
+begin
+  mList.Clear;
+end;
+
 function Tbookcollection.Get(Index: Integer): Tbook;
 begin
   result:=(TBook (mList.Items[index]));

+ 163 - 29
src/main.pas

@@ -5,14 +5,14 @@ unit main;
 interface
 
 uses
-  Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls,
-  Book, BookCollection, LCLIntf, LResources, StdCtrls, LCLType, IniFiles, unitSettingsDialog;
+  Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls, LazFileUtils,
+  Book, BookCollection, LCLIntf, LResources, StdCtrls, LCLType, IniFiles, unitSettingsDialog,
+  unitCoverWorker, unitStorageXML;
 
 
 type
 
   { Tform1 }
-
   Tform1 = class(Tform)
     EditSearch: Tedit;
     ButtonSettings: Timage;
@@ -20,6 +20,7 @@ type
     ButtonAdd: Timage;
     Opendialog1: Topendialog;
     PanelBackground: Tscrollbox;
+    procedure FormResize(Sender: TObject);
     procedure Buttonaddclick(Sender: Tobject);
     procedure Buttonaddmouseenter(Sender: Tobject);
     procedure Buttonaddmouseleave(Sender: Tobject);
@@ -44,6 +45,8 @@ type
     function getCoverIndex(cover:TImage):Integer;
   private
     mAdd,mAddHover,mGear,mGearHover:TPicture;
+    LayoutTimer: TTimer;
+    procedure LayoutTimerTick(Sender: TObject);
   public
     { public declarations }
   end;
@@ -64,12 +67,25 @@ implementation
 
 { Tform1 }
 
+procedure TForm1.FormResize(Sender: TObject);
+begin
+  // debounce: restart the timer, don’t layout on every pixel move
+  LayoutTimer.Enabled := False;
+  LayoutTimer.Enabled := True;
+end;
+
+procedure TForm1.LayoutTimerTick(Sender: TObject);
+begin
+  LayoutTimer.Enabled := False;   // one-shot
+  RearrangeBooksOnScreen;
+end;
+
 procedure Tform1.Panelbackgroundclick(Sender: Tobject);
 begin
  ActiveControl:=PanelBackground;
 
  UnselectAll;
- PanelBackground.Repaint;
+ PanelBackground.Invalidate;
 End;
 
 procedure Tform1.Panelbackgrounddragdrop(Sender, Source: Tobject; X, Y: Integer);
@@ -79,7 +95,8 @@ begin
  dest:=getBookIndexAtPoint(X,Y);
    if (src > -1) and (dest > -1) then BookList.SwapBooks(src,dest);
    UnselectAll;
-   RearrangeBooksOnScreen();
+   PanelBackground.Invalidate;
+   //RearrangeBooksOnScreen();
 End;
 
 procedure Tform1.Panelbackgrounddragover(Sender, Source: Tobject; X,
@@ -113,37 +130,126 @@ begin
 
 End;
 
-procedure Tform1.Rearrangebooksonscreen;
-var i,x,y:Integer;
+procedure TForm1.RearrangeBooksOnScreen;
+var
+  visibleCovers: array of TImage;
+  i, j, k, countVisible: Integer;
+  availW, minGap, rowStart, rowCount: Integer;
+  curY: Integer;
+  x: Double;
+  gap: Double;
+  cover: TImage;
+
+  function PanelClientWidth: Integer;
+  begin
+    // Use client width (exclude borders/scrollbar)
+    Result := PanelBackground.ClientWidth;
+    if Result <= 0 then Result := PanelBackground.Width;
+  end;
+
+  procedure CollectVisible;
+  var i : Integer;
+  begin
+    SetLength(visibleCovers, 0);
+    for i := 0 to BookList.Count - 1 do
+    begin
+      cover := BookList.Books[i].Cover;
+      if Assigned(cover) and cover.Visible then
+      begin
+        SetLength(visibleCovers, Length(visibleCovers) + 1);
+        visibleCovers[High(visibleCovers)] := cover;
+      end;
+    end;
+    countVisible := Length(visibleCovers);
+  end;
+
+  // Can we fit N items with at least minGap spacing including left+right margins?
+  function FitsWithMinGaps(n: Integer; width: Integer; gapPx: Integer): Boolean;
+  var need: Integer;
+  begin
+    // total = n*bookWidth + (n+1)*gap  (edge gaps included)
+    need := (n * bookWidth) + ((n + 1) * gapPx);
+    Result := need <= width;
+  end;
+
 begin
+  PanelBackground.DisableAlign;
+  try
+    availW := PanelClientWidth;
+    if availW <= 0 then Exit;
 
- x:=0;
- y:=0;
+    minGap := Xspace;     // your existing horizontal spacing as the minimum
+    curY   := Yspace;     // top margin
+    CollectVisible;
 
- for i:= 0 to BookList.Count-1 do
- begin
-    if X+Xspace > PanelBackground.Width-bookWidth then
+    // Early exit: nothing to place
+    if countVisible = 0 then Exit;
+
+    // Ensure covers have correct size (in case they were recreated)
+    for i := 0 to countVisible - 1 do
     begin
-      X:=0;
-      Y:=Y+Yspace+bookHeight+26;
+      visibleCovers[i].Width  := bookWidth;
+      visibleCovers[i].Height := bookHeight;
+      visibleCovers[i].Parent := PanelBackground;
     end;
-    with BookList.Books[i] do
+
+    rowStart := 0;
+    while rowStart < countVisible do
     begin
-      Cover.Left:=X+Xspace;
-      Cover.Top:=Y+Yspace;
-      X:=X+Xspace+bookWidth;
+      // Determine how many items fit in this row with at least minGap gutters.
+      rowCount := 1;
+      while (rowStart + rowCount < countVisible)
+        and FitsWithMinGaps(rowCount + 1, availW, minGap) do
+        Inc(rowCount);
+
+      // Compute the gap for this row:
+      // - For full rows, distribute leftover width evenly across (rowCount+1) gaps.
+      // - For the last row (rowStart+rowCount = countVisible), keep it left-aligned (minGap).
+      if (rowStart + rowCount) < countVisible then
+      begin
+        // Full row → justified
+        gap := (availW - (rowCount * bookWidth)) / (rowCount + 1);
+        if gap < minGap then gap := minGap; // safety
+      end
+      else
+      begin
+        // Last row → left align
+        gap := minGap; // safety
+      end;
+
+      // Place row items: start at left edge gap, then [cover + gap] repeated.
+      x := gap;
+      for j := 0 to rowCount - 1 do
+      begin
+        k := rowStart + j;
+        cover := visibleCovers[k];
+        cover.Left := Round(x);
+        cover.Top  := curY;
+        x := x + bookWidth + gap;
+      end;
+
+      // Next row Y
+      curY := curY + bookHeight + Yspace + 26;
+      Inc(rowStart, rowCount);
     end;
-  end;
- PanelBackground.Repaint;
 
+    // Optional: ensure panel is tall enough; comment out if not needed.
+    // PanelBackground.AutoSize := False;
+    // PanelBackground.Height := curY + Yspace;
+  finally
+    PanelBackground.EnableAlign;
+    
+    PanelBackground.Invalidate;
+  end;
 end;
 
+
 procedure Tform1.Panelbackgroundresize(Sender: Tobject);
 begin
  RearrangeBooksOnScreen();
 
  EditSearch.Left:=Width-EditSearch.Width-20;
- End;
+End;
 
 function Tform1.Getbookindexatpoint(X, Y: Integer): Integer;
 var i:Integer;
@@ -152,8 +258,9 @@ begin
  for i:=0 to BookList.Count-1 do
  begin
    cover:=BookList.Books[i].Cover;
-   if (cover.Left > X) and (cover.Left - bookWidth < X) and (cover.Top <= Y) and (cover.Top + bookHeight > Y) then
-   begin
+    if (X >= cover.Left) and (X <= cover.Left + cover.Width) and
+      (Y >= cover.Top) and (Y <= cover.Top + cover.Height) then
+    begin
      result :=i;
      exit;
    end;
@@ -187,7 +294,7 @@ end;
 
 procedure Tform1.Formclose(Sender: Tobject; var Closeaction: Tcloseaction);
 begin
-BookList.StoreData(dataPath);
+SaveBooksXML(dataPath, BookList);
 BookList.Destroy;
 End;
 
@@ -207,7 +314,10 @@ begin
   book.Cover.Width:=bookWidth;
   book.Cover.Height:=bookHeight;
   book.Cover.Parent:=PanelBackground;
+   
   end;
+  CoverWorkerEnqueueBookIfMissing(Book);
+  CoverWorkerStart;
   RearrangeBooksOnScreen();
 end;
 End;
@@ -267,7 +377,7 @@ var
  cfgDir, cfgPath, dataDir: String;
  ini: TIniFile;
 begin
- bookWidth:=150;
+ bookWidth:=130;
  bookHeight:=200;
  Xspace:=40;
  Yspace:=25;
@@ -279,6 +389,15 @@ begin
  background:=TPicture.Create;
  background.LoadFromLazarusResource('shelf');
 
+ PanelBackground.DoubleBuffered := True; // reduce flicker
+
+ Self.OnResize := @FormResize;
+
+ LayoutTimer := TTimer.Create(Self);
+ LayoutTimer.Enabled  := False;
+ LayoutTimer.Interval := 60;            // ~60ms debounce feels snappy
+ LayoutTimer.OnTimer  := @LayoutTimerTick;
+ 
  mAdd:=TPicture.Create;
  mAddHover:=Tpicture.Create;
  mGear:=Tpicture.Create;
@@ -292,10 +411,10 @@ begin
 
  // Load config.ini if present to resolve dataPath and options
   {$IFDEF MSWINDOWS}
-  cfgDir := GetEnvironmentVariableUTF8('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
+  cfgDir := GetEnvironmentVariable('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
   {$ENDIF}
   {$IFDEF UNIX}
-  cfgDir := GetEnvironmentVariableUTF8('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
+  cfgDir := GetEnvironmentVariable('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
   {$ENDIF}
   if not DirectoryExistsUTF8(cfgDir) then CreateDirUTF8(cfgDir);
 
@@ -310,12 +429,12 @@ begin
   end;
 
   if not DirectoryExistsUTF8(dataDir) then CreateDirUTF8(dataDir);
-  dataPath := IncludeTrailingPathDelimiter(dataDir) + 'data.dat';
+  dataPath := IncludeTrailingPathDelimiter(dataDir) + 'books.xml';
 
  BookList:=TBookCollection.Create;
 
  if FileExistsUTF8(dataPath) then
-    BookList.LoadData(dataPath, PanelBackground);
+    LoadBooksXML(dataPath, BookList, PanelBackground);
 
 
  for i:=0 to BookList.Count-1 do
@@ -325,11 +444,26 @@ begin
     Cover.Width:=bookWidth;
     Cover.Height:=bookHeight;
     Cover.Parent:=PanelBackground;
+    EnsureScaledToCoverSize;
   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
+ CoverWorkerEnqueueMissingFromBookList(BookList);
+ CoverWorkerStart;
+
 End;
 
 procedure Tform1.Formkeydown(Sender: Tobject; var Key: Word; Shift: Tshiftstate);

+ 14 - 11
src/myBookShelf.lpr

@@ -2,22 +2,25 @@ program myBookShelf;
 
 {$mode objfpc}{$H+}
 
+// Enable pthread support on Unix before any other unit uses threads.
+{$IFDEF UNIX}{$DEFINE UseCThreads}{$ENDIF}
+
 uses
-  {$IFDEF UNIX}{$IFDEF UseCThreads}
-  Cthreads,
-  {$ENDIF}{$ENDIF}
-  Interfaces, // this includes the LCL widgetset
-  Forms, main, book, bookCollection, UnitBookDialog, unitSettingsDialog
-  { you can add units after this };
+  {$IFDEF UseCThreads}
+  cthreads,
+  {$ENDIF}
+  Interfaces,           // LCL widgetset (gtk2/qt5 decided by your env/flags)
+  Forms,
+  main,                 // your main form unit (TForm1, Form1)
+  unitSettingsDialog,   // settings dialog unit
+  unitCoverWorker;      // background PDF cover worker
 
 {$R *.res}
 
 begin
-  Requirederivedformresource:=True;
+  RequireDerivedFormResource := True;
+  Application.Scaled := True;
   Application.Initialize;
-  Application.Createform(Tform1, Form1);
-  Application.Createform(Tbookeditdialog, Bookeditdialog);
-  Application.CreateForm(TSettingsDialog, SettingsDialog);
+  Application.CreateForm(TForm1, Form1);
   Application.Run;
 end.
-

binární
src/myBookShelf.res


+ 206 - 0
src/unitCoverWorker.pas

@@ -0,0 +1,206 @@
+unit unitCoverWorker;
+
+{$mode objfpc}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, Process, LCLIntf, Book, BookCollection, FileUtil;
+
+{ Call this once after loading your data: it scans the list and enqueues
+  only the PDFs that still use the generic cover (i.e. ImagePath=''). }
+procedure CoverWorkerEnqueueMissingFromBookList(AList: TBookCollection);
+
+{ Call this whenever you add/edit a single book and want it queued if needed. }
+procedure CoverWorkerEnqueueBookIfMissing(B: TBook);
+
+{ Starts the background worker (idempotent). It will exit by itself when the
+  queue is empty. Call again later to restart if you enqueue more books. }
+procedure CoverWorkerStart;
+
+implementation
+
+type
+  { Simple worker that drains a TThreadList queue of TBook references }
+  TCoverWorker = class(TThread)
+  private
+    FApplyBook: TBook;
+    FApplyImg : String;
+    procedure DoApplyCover; // runs in main thread
+  protected
+    procedure Execute; override;
+  end;
+
+var
+  GPdfQueue: TThreadList; // holds TBook references
+  GWorker  : TCoverWorker;
+
+{--- helpers ------------------------------------------------------------------}
+
+function IsPdf(const Path: String): Boolean;
+begin
+  Result := LowerCase(ExtractFileExt(Path)) = '.pdf';
+end;
+
+function HasGenericCover(B: TBook): Boolean;
+begin
+  // Our Book.SetImage leaves ImagePath='' when using the generic resource
+  Result := (Trim(B.ImagePath) = '');
+end;
+
+function GeneratePdfCover(const PdfPath: String): String;
+var
+  OutBase, Converter: String;
+  Proc: TProcess;
+begin
+  Result := '';
+
+  // If a sibling JPG already exists, just return it
+  if FileExists(ChangeFileExt(PdfPath, '.jpg')) then
+    Exit(ChangeFileExt(PdfPath, '.jpg'));
+
+  // look for pdftoppm in PATH (Poppler utilities)
+  Converter := FindDefaultExecutablePath('pdftoppm');
+  if Converter = '' then Exit; // poppler not installed
+
+  OutBase := ChangeFileExt(PdfPath, ''); // /path/book.pdf -> /path/book
+
+  Proc := TProcess.Create(nil);
+  try
+    Proc.Executable := Converter;
+    // pdftoppm -jpeg -singlefile -f 1 -l 1 <pdf> <out_base>
+    Proc.Parameters.Add('-jpeg');
+    Proc.Parameters.Add('-singlefile');
+    Proc.Parameters.Add('-f'); Proc.Parameters.Add('1');
+    Proc.Parameters.Add('-l'); Proc.Parameters.Add('1');
+    Proc.Parameters.Add(PdfPath);
+    Proc.Parameters.Add(OutBase);
+    Proc.Options := [poWaitOnExit];
+    Proc.ShowWindow := swoHIDE;
+    Proc.Execute;
+  finally
+    Proc.Free;
+  end;
+
+  if FileExists(OutBase + '.jpg') then
+    Result := OutBase + '.jpg';
+end;
+
+procedure EnsureQueue;
+begin
+  if GPdfQueue = nil then
+    GPdfQueue := TThreadList.Create;
+end;
+
+{--- public API ----------------------------------------------------------------}
+
+procedure CoverWorkerEnqueueMissingFromBookList(AList: TBookCollection);
+var
+  i: Integer;
+  l: TList;
+begin
+  if AList = nil then Exit;
+  EnsureQueue;
+  l := GPdfQueue.LockList;
+  try
+    for i := 0 to AList.Count - 1 do
+      if IsPdf(AList.Books[i].FilePath) and HasGenericCover(AList.Books[i]) then
+        l.Add(AList.Books[i]);
+  finally
+    GPdfQueue.UnlockList;
+  end;
+end;
+
+procedure CoverWorkerEnqueueBookIfMissing(B: TBook);
+var
+  l: TList;
+begin
+  if (B = nil) then Exit;
+  if not (IsPdf(B.FilePath) and HasGenericCover(B)) then Exit;
+  EnsureQueue;
+  l := GPdfQueue.LockList;
+  try
+    if l.IndexOf(B) < 0 then
+      l.Add(B);
+  finally
+    GPdfQueue.UnlockList;
+  end;
+end;
+
+procedure CoverWorkerStart;
+begin
+  EnsureQueue;
+  if (GWorker = nil) or (GWorker.Finished) then
+  begin
+    GWorker := TCoverWorker.Create(True);
+    GWorker.FreeOnTerminate := True;
+    GWorker.Start;
+  end;
+end;
+
+{--- worker --------------------------------------------------------------------}
+
+procedure TCoverWorker.DoApplyCover;
+begin
+  // This runs in the main/UI thread
+  try
+    if Assigned(FApplyBook) and (FApplyImg <> '') and FileExists(FApplyImg) then
+    begin
+      FApplyBook.ImagePath := FApplyImg;  // triggers SetImage + pre-scale
+      FApplyBook.EnsureScaledToCoverSize; // in case layout changed
+    end;
+  except
+    // ignore UI exceptions, keep worker going
+  end;
+end;
+
+procedure TCoverWorker.Execute;
+var
+  l: TList;
+  B: TBook;
+  Img: String;
+begin
+  // drain the queue
+  while not Terminated do
+  begin
+    // Pop one item
+    B := nil;
+    l := GPdfQueue.LockList;
+    try
+      if (l <> nil) and (l.Count > 0) then
+      begin
+        B := TBook(l[0]);
+        l.Delete(0);
+      end;
+    finally
+      GPdfQueue.UnlockList;
+    end;
+
+    if B = nil then
+      Break; // queue empty → exit thread
+
+    // Skip if it no longer needs a cover
+    if not (IsPdf(B.FilePath) and HasGenericCover(B)) then
+    begin
+      Sleep(5);
+      Continue;
+    end;
+
+    // Generate cover (background thread)
+    Img := GeneratePdfCover(B.FilePath);
+
+    if (Img <> '') and FileExists(Img) then
+    begin
+      // Pass data to main thread via fields + Synchronize
+      FApplyBook := B;
+      FApplyImg  := Img;
+      Synchronize(@DoApplyCover);
+      FApplyBook := nil;
+      FApplyImg  := '';
+    end;
+
+    Sleep(5); // be nice to the UI event loop
+  end;
+end;
+
+end.

+ 174 - 0
src/unitStorageXML.pas

@@ -0,0 +1,174 @@
+unit unitStorageXML;
+
+{$mode objfpc}{$H+}
+
+interface
+
+uses
+  Classes, SysUtils, DOM, XMLRead, XMLWrite, Controls, Book, BookCollection,
+  LazUTF8;
+
+{ Load books from an XML file into AList. Clears the collection first.
+  Parent is where TBook cover controls should be parented (e.g., PanelBackground). }
+procedure LoadBooksXML(const FileName: String; AList: TBookCollection; Parent: TWinControl);
+
+{ Save AList to XML. Writes to <FileName>.tmp then atomically renames to FileName.
+  Ensures no duplicate entries are written (based on a stable key). }
+procedure SaveBooksXML(const FileName: String; AList: TBookCollection);
+
+implementation
+
+function NormLower(const S: String): String; inline;
+begin
+  Result := UTF8LowerCase(Trim(S));
+end;
+
+function KeyFor(const Title, Authors, Isbn, FilePath: String): String;
+var fp: String;
+begin
+  if Trim(FilePath) <> '' then
+  begin
+    fp := ExpandFileName(FilePath);
+    Exit('fp:' + NormLower(fp));
+  end;
+  if Trim(Isbn) <> '' then
+    Exit('isbn:' + NormLower(Isbn));
+  Exit('ti:' + NormLower(Title) + '|' + NormLower(Authors));
+end;
+
+procedure LoadBooksXML(const FileName: String; AList: TBookCollection; Parent: TWinControl);
+var
+  Doc   : TXMLDocument;
+  Root  : TDOMElement;
+  Node  : TDOMNode;
+  El    : TDOMElement;
+  B     : TBook;
+  title, authors, isbn, filep, imagep: String;
+  seen  : TStringList;
+  key   : String;
+begin
+  if (AList = nil) or (Parent = nil) then Exit;
+
+  if not FileExists(FileName) then
+  begin
+    // Nothing to load; ensure list is empty
+    if AList.Count > 0 then
+      AList.Clear;
+    Exit;
+  end;
+
+  ReadXMLFile(Doc, FileName);
+  try
+    Root := Doc.DocumentElement;   // <bookshelf version="1">
+    if (Root = nil) or (UTF8LowerCase(Root.TagName) <> 'bookshelf') then
+      Exit;
+
+    // Start fresh to avoid duplication-on-startup
+    AList.Clear;
+
+    seen := TStringList.Create;
+    try
+      seen.Sorted := True; seen.Duplicates := dupIgnore;
+
+      Node := Root.FirstChild;
+      while Node <> nil do
+      begin
+        if (Node.NodeType = ELEMENT_NODE) then
+        begin
+          El := TDOMElement(Node);
+          if UTF8LowerCase(El.TagName) = 'book' then
+          begin
+            title   := El.GetAttribute('title');
+            authors := El.GetAttribute('authors');
+            isbn    := El.GetAttribute('isbn');
+            filep   := El.GetAttribute('file');
+            imagep  := El.GetAttribute('image');
+
+            key := KeyFor(title, authors, isbn, filep);
+            if seen.IndexOf(key) < 0 then
+            begin
+              seen.Add(key);
+
+              // Recreate the book object and add to collection
+              B := TBook.Create(Parent);
+              B.Title   := title;
+              B.Authors := authors;
+              B.ISBN    := isbn;
+              if filep <> '' then B.FilePath  := filep;   // will try sibling images/pdf cover
+              if imagep <> '' then B.ImagePath := imagep; // if a specific cover was saved
+
+              // NOTE: If your BookCollection uses a different adder, adjust this line:
+              AList.AddBook(B);
+            end;
+          end;
+        end;
+        Node := Node.NextSibling;
+      end;
+    finally
+      seen.Free;
+    end;
+  finally
+     Doc.Free;
+  end;
+end;
+
+procedure SaveBooksXML(const FileName: String; AList: TBookCollection);
+var
+  Doc   : TXMLDocument;
+  Root  : TDOMElement;
+  El    : TDOMElement;
+  i     : Integer;
+  B     : TBook;
+  tmp   : String;
+  seen  : TStringList;
+  key   : String;
+begin
+  if AList = nil then Exit;
+
+  // Build XML document
+  Doc := TXMLDocument.Create;
+  try
+    Root := Doc.CreateElement('bookshelf');
+    Root.SetAttribute('version','1');
+    Doc.AppendChild(Root);
+
+    seen := TStringList.Create;
+    try
+      seen.Sorted := True; seen.Duplicates := dupIgnore;
+
+      for i := 0 to AList.Count - 1 do
+      begin
+        B := AList.Books[i];
+        key := KeyFor(B.Title, B.Authors, B.ISBN, B.FilePath);
+        if seen.IndexOf(key) >= 0 then
+          Continue; // skip duplicates in memory
+
+        seen.Add(key);
+
+        El := Doc.CreateElement('book');
+        El.SetAttribute('title',   B.Title);
+        El.SetAttribute('authors', B.Authors);
+        El.SetAttribute('isbn',    B.ISBN);
+        El.SetAttribute('file',    B.FilePath);
+        El.SetAttribute('image',   B.ImagePath);
+
+        Root.AppendChild(El);
+      end;
+    finally
+      seen.Free;
+    end;
+
+    // Atomic write: to .tmp then rename
+    tmp := FileName + '.tmp';
+    WriteXMLFile(Doc, tmp);
+    // Ensure target dir exists, then replace
+    if FileExists(FileName) then
+      DeleteFile(FileName);
+    if not RenameFile(tmp, FileName) then
+      raise Exception.CreateFmt('Failed to write %s', [FileName]);
+  finally
+    Doc.Free;
+  end;
+end;
+
+end.

+ 2 - 2
src/unitbookdialog.lfm

@@ -7,7 +7,7 @@ object BookEditDialog: TBookEditDialog
   BorderStyle = bsDialog
   Caption = 'Edit Book Info'
   ClientHeight = 347
-  ClientWidth = 806
+  ClientWidth = 856
   FormStyle = fsStayOnTop
   OnCreate = FormCreate
   LCLVersion = '1.6.4.0'
@@ -31,7 +31,7 @@ object BookEditDialog: TBookEditDialog
     Left = 329
     Height = 29
     Top = 128
-    Width = 463
+    Width = 400
     TabOrder = 2
     Text = 'ISBN'
   end

+ 173 - 1
src/unitbookdialog.pas

@@ -6,7 +6,7 @@ interface
 
 uses
   Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls,
-  StdCtrls, Buttons, Book;
+  StdCtrls, Buttons, Book, fphttpclient, fpjson, jsonparser, opensslsockets;
 
 type
 
@@ -28,12 +28,19 @@ type
     Label5: Tlabel;
     Opendialog1: Topendialog;
     Panel1: Tpanel;
+    ButtonLookup:TButton;
     procedure Buttoncancelclick(Sender: Tobject);
     procedure Buttonsaveclick(Sender: Tobject);
     procedure EditFilePathChange(Sender: Tobject);
     procedure Formcreate(Sender: Tobject);
     procedure Imagebookcoverclick(Sender: Tobject);
     procedure LoadBook(Book:TBook);
+    
+    // add handlers/helpers
+    procedure ButtonLookupClick(Sender: TObject);
+    function DownloadURLToFile(const URL, DestPath: String): Boolean;
+    procedure TryLookupByISBN(const isbn: String);
+    function HTTPGet(const URL: String): String;
   private
     mBook:TBook;
     { private declarations }
@@ -55,8 +62,173 @@ procedure Tbookeditdialog.Formcreate(Sender: Tobject);
 begin
   ActiveControl:=ButtonSave;
 
+  ButtonLookup := TButton.Create(Self);
+  ButtonLookup.Parent := Self;
+  ButtonLookup.Caption := 'Lookup';
+  ButtonLookup.Top := EditISBN.Top - 2;
+  ButtonLookup.Left := EditISBN.Left + EditISBN.Width + 8;
+  ButtonLookup.OnClick := @ButtonLookupClick;
 End;
 
+procedure TBookEditDialog.ButtonLookupClick(Sender: TObject);
+begin
+  if Trim(EditISBN.Text) <> '' then
+    TryLookupByISBN(Trim(EditISBN.Text))
+  else
+    ShowMessage('Enter an ISBN first.');
+end;
+
+function TBookEditDialog.DownloadURLToFile(const URL, DestPath: String): Boolean;
+var c: TFPHTTPClient;
+begin
+  Result := False;
+  c := TFPHTTPClient.Create(nil);
+  try
+    c.AllowRedirect := True;
+    c.AddHeader('User-Agent','myBookShelf/1.0');
+    c.ConnectTimeout := 5000;
+    c.IOTimeout := 15000;
+    c.Get(URL, DestPath);
+    Result := FileExists(DestPath);
+  finally
+    c.Free;
+  end;
+end;
+
+function TBookEditDialog.HTTPGet(const URL: String): String;
+var c: TFPHTTPClient;
+begin
+  Result := '';
+  c := TFPHTTPClient.Create(nil);
+  try
+    c.AllowRedirect := True;
+    c.AddHeader('User-Agent','myBookShelf/1.0 (+https://example.invalid)');
+    c.ConnectTimeout := 5000;
+    c.IOTimeout := 8000;
+    Result := c.Get(URL);
+  finally
+    c.Free;
+  end;
+end;
+
+procedure TBookEditDialog.TryLookupByISBN(const isbn: String);
+var
+  key, jstr: String;
+  jroot, jbook, jauth, jcover: TJSONData;
+  title, authorsJoined: String;
+  i: Integer;
+  base, coverPath: String;
+
+  function FirstNonEmpty(const a,b:String):String;
+  begin if a<>'' then exit(a); exit(b); end;
+
+  function TryGetJSON(const u1, u2: String): String;
+  begin
+    try Result := HTTPGet(u1); if Result<>'' then exit; except end;
+    try Result := HTTPGet(u2); except end;
+  end;
+
+begin
+  // 1) Fetch metadata (HTTPS first, HTTP fallback)
+  key := 'ISBN:' + Trim(isbn);
+  jstr := TryGetJSON(
+    Format('https://openlibrary.org/api/books?bibkeys=%s&format=json&jscmd=data',[key]),
+    Format('http://openlibrary.org/api/books?bibkeys=%s&format=json&jscmd=data',[key])
+  );
+
+  title := ''; authorsJoined := '';
+  if jstr <> '' then
+  try
+    jroot := GetJSON(jstr);
+    try
+      jbook := jroot.FindPath(key);
+      if (jbook <> nil) then
+      begin
+        // title
+        if jbook.FindPath('title') <> nil then
+          title := jbook.FindPath('title').AsString;
+
+        // authors array -> join names with ", "
+        if jbook.FindPath('authors') <> nil then
+        begin
+          jauth := jbook.FindPath('authors');
+          if (jauth.JSONType = jtArray) then
+            for i := 0 to jauth.Count - 1 do
+            begin
+              if i > 0 then authorsJoined := authorsJoined + ', ';
+              authorsJoined := authorsJoined + jauth.Items[i].FindPath('name').AsString;
+            end;
+        end;
+
+        // If authors empty, fall back to by_statement (from isbn.json)
+        if authorsJoined = '' then
+        begin
+          jstr := TryGetJSON(
+            Format('https://openlibrary.org/isbn/%s.json',[isbn]),
+            Format('http://openlibrary.org/isbn/%s.json',[isbn])
+          );
+          if jstr <> '' then
+          try
+            jcover := GetJSON(jstr);
+            try
+              if jcover.FindPath('by_statement') <> nil then
+                authorsJoined := jcover.FindPath('by_statement').AsString;
+              if (title = '') and (jcover.FindPath('title')<>nil) then
+                title := jcover.FindPath('title').AsString;
+            finally
+              jcover.Free;
+            end;
+          except end;
+        end;
+      end;
+    finally
+      jroot.Free;
+    end;
+  except
+    // ignore; handled below
+  end;
+
+  // 2) Apply to UI
+  if title <> '' then EditTitle.Text := title;
+  if authorsJoined <> '' then EditAuthors.Text := authorsJoined;
+
+  // 3) Attempt cover download next to the chosen file
+  if Trim(EditFilePath.Text) <> '' then
+  begin
+    base := ChangeFileExt(EditFilePath.Text, '');
+    coverPath := base + '.jpg';
+
+    // Try HTTPS then HTTP; L size then M size
+    if not DownloadURLToFile(
+         Format('https://covers.openlibrary.org/b/isbn/%s-L.jpg',[isbn]),
+         coverPath) then
+      if not DownloadURLToFile(
+           Format('http://covers.openlibrary.org/b/isbn/%s-L.jpg',[isbn]),
+           coverPath) then
+        if not DownloadURLToFile(
+             Format('https://covers.openlibrary.org/b/isbn/%s-M.jpg',[isbn]),
+             coverPath) then
+          DownloadURLToFile(
+            Format('http://covers.openlibrary.org/b/isbn/%s-M.jpg',[isbn]),
+            coverPath);
+
+    if FileExists(coverPath) then
+    begin
+      EditImagePath.Text := coverPath;
+      try
+        ImageBookCover.Picture.LoadFromFile(coverPath);
+      except
+        // ignore preview errors
+      end;
+    end;
+  end;
+
+  // 4) If nothing changed, tell the user (so it’s not silent)
+  if (title = '') and (authorsJoined = '') and
+     ((Trim(EditFilePath.Text)='') or not FileExists(ChangeFileExt(EditFilePath.Text,'.jpg'))) then
+    ShowMessage('No metadata/cover found for ISBN ' + isbn + '.');
+end;
+
 procedure Tbookeditdialog.Imagebookcoverclick(Sender: Tobject);
 begin
   if Opendialog1.Execute then

+ 3 - 3
src/unitsettingsdialog.pas

@@ -6,7 +6,7 @@ interface
 
 uses
   Classes, SysUtils, FileUtil, Forms, Controls, Graphics, Dialogs,
-  StdCtrls, Buttons, IniFiles;
+  StdCtrls, Buttons, IniFiles, LazFileUtils;
 
 type
   TSettingsDialog = class(TForm)
@@ -83,10 +83,10 @@ end;
 function TSettingsDialog.ConfigDir: String;
 begin
   {$IFDEF MSWINDOWS}
-  Result := GetEnvironmentVariableUTF8('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
+  Result := GetEnvironmentVariable('APPDATA') + DirectorySeparator + 'mybookshelf' + DirectorySeparator;
   {$ENDIF}
   {$IFDEF UNIX}
-  Result := GetEnvironmentVariableUTF8('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
+  Result := GetEnvironmentVariable('HOME') + DirectorySeparator + '.mybookshelf' + DirectorySeparator;
   {$ENDIF}
   if not DirectoryExistsUTF8(Result) then CreateDirUTF8(Result);
 end;