home bbs chatroom files | messages | login ]

      FPASC160             PASCAL             593 messages      

[ list messages | list forums | previous ]

  Msg # 593 of 593 on FPASC160, Saturday 7-10-26, 9:48  
  From: SHAWN HIGHFIELD  
  To: ALL  
  Subj: Gopher client  
  Here's a gopher client I had gemini write and claude fix.  I take no credit  
  for any of the code, it compiles and works pretty good for something a  
  computer generated in about 30 mins.  
    
   ----- gophernav.pas begins -----  
  program tiegopher;  
  (* TieGopher July 10 2026  
   Freeware do what you want license.  
   Written by Gemini fixed by Claude  
  *)  
    
  {$mode objfpc}{$H+}  
    
  uses  
   Crt, Classes, SysUtils, ssockets, Unix;  
    
  type  
   TMenuItem = record  
   ItemType: Char;  
   Desc: String;  
   Selector: String;  
   Host: String;  
   Port: Word;  
   IsNavigable: Boolean;  
   end;  
    
   THistoryItem = record  
   Host: String;  
   Port: Word;  
   Selector: String;  
   end;  
    
  var  
   CurrentHost: String = 'gopher.floodgap.com';  
   CurrentPort: Word = 70;  
   CurrentSelector: String = '';  
    
   MenuItems: array of TMenuItem;  
   NavMap: array of Integer;  
   History: array of THistoryItem;  
    
   // TUI State variables  
   ActiveNavIndex: Integer;  
   ScrollOffset: Integer;  
   TargetMenu: TMenuItem;  
   SearchQuery: String;  
    
  // ---------------------------------------------------------  
  // Fetches data from a Gopher server and returns a StringList  
  // ---------------------------------------------------------  
  function FetchData(Host: String; Port: Word; Selector: String): TStringList;  
  var  
   Stream: TInetSocket;  
   MemStream: TMemoryStream;  
   Buffer: array[0..4095] of Byte;  
   BytesRead: Integer;  
   Request: String;  
  begin  
   Result := TStringList.Create;  
   MemStream := TMemoryStream.Create;  
   try  
   try  
   Stream := TInetSocket.Create(Host, Port);  
   try  
   Request := Selector + #13#10;  
   Stream.WriteBuffer(Request[1], Length(Request));  
   repeat  
   BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer));  
   if BytesRead > 0 then  
   MemStream.Write(Buffer[0], BytesRead);  
   until BytesRead <= 0;  
   finally  
   Stream.Free;  
   end;  
   MemStream.Position := 0;  
   Result.LoadFromStream(MemStream);  
   except  
   on E: Exception do  
   Result.Add('3Connection Error: ' + E.Message + #9 + 'fake' + #9 +  
  'error.host' + #9 + '1');  
   end;  
   finally  
   MemStream.Free;  
   end;  
  end;  
    
  // ---------------------------------------------------------  
  // Fetches raw bytes (for binary files) into a TMemoryStream  
  // ---------------------------------------------------------  
  function FetchRaw(Host: String; Port: Word; Selector: String):  
  TMemoryStream;  
  var  
   Stream: TInetSocket;  
   Buffer: array[0..4095] of Byte;  
   BytesRead: Integer;  
   Request: String;  
  begin  
   Result := TMemoryStream.Create;  
   try  
   Stream := TInetSocket.Create(Host, Port);  
   try  
   Request := Selector + #13#10;  
   Stream.WriteBuffer(Request[1], Length(Request));  
   repeat  
   BytesRead := Stream.Read(Buffer[0], SizeOf(Buffer));  
   if BytesRead > 0 then  
   Result.Write(Buffer[0], BytesRead);  
   until BytesRead <= 0;  
   finally  
   Stream.Free;  
   end;  
   Result.Position := 0;  
   except  
   on Exception do  
   Result.Position := 0; { return whatever we have; caller checks Size  
  }  
   end;  
  end;  
    
  // ---------------------------------------------------------  
  // Parses a raw tab-delimited Gopher line into a TMenuItem  
  // ---------------------------------------------------------  
  function ParseMenuLine(const Line: String): TMenuItem;  
  var  
   Parts: array of String;  
   i: Integer;  
   CurStr: String;  
  begin  
   Result.ItemType := 'i';  
   if Length(Line) = 0 then Exit;  
    
   Result.ItemType := Line[1];  
    
   SetLength(Parts, 0);  
   CurStr := '';  
   for i := 2 to Length(Line) do  
   begin  
   if Line[i] = #9 then  
   begin  
   SetLength(Parts, Length(Parts) + 1);  
   Parts[High(Parts)] := CurStr;  
   CurStr := '';  
   end  
   else  
   CurStr := CurStr + Line[i];  
   end;  
   SetLength(Parts, Length(Parts) + 1);  
   Parts[High(Parts)] := CurStr;  
    
   Result.Desc := ''; Result.Selector := ''; Result.Host := ''; Result.Port  
  := 70;  
   if Length(Parts) > 0 then Result.Desc := Parts[0];  
   if Length(Parts) > 1 then Result.Selector := Parts[1];  
   if Length(Parts) > 2 then Result.Host := Parts[2];  
   if Length(Parts) > 3 then Result.Port := StrToIntDef(Trim(Parts[3]), 70);  
    
   Result.IsNavigable := Result.ItemType in  
   ['0', '1', '7', 'h', { text, menu, search, html-link }  
   '4', '5', '6', '9', { encoded / binary files }  
   'g', 'I', 'p', { images }  
   's', 'd', ';']; { sound, document, video }  
  end;  
    
  // ---------------------------------------------------------  
  // Downloads and displays a Text file (Type 0)  
  // ---------------------------------------------------------  
  procedure ShowText(Host: String; Port: Word; Selector: String);  
  var  
   Data: TStringList;  
   i, LineCount: Integer;  
   Cmd: String;  
  begin  
   ClrScr;  
   WriteLn('Fetching text from ', Host, '...');  
   Data := FetchData(Host, Port, Selector);  
   ClrScr;  
    
   LineCount := 0;  
   for i := 0 to Data.Count - 1 do  
   begin  
   if (Data[i] = '.') and (i = Data.Count - 1) then Break;  
    
   WriteLn(Data[i]);  
   Inc(LineCount);  
    
   if LineCount mod (WindMaxY - 2) = 0 then  
   begin  
   TextColor(LightCyan);  
   Write('-- Press Enter for more, or "q" to stop --');  
   NormVideo;  
   ReadLn(Cmd);  
   if LowerCase(Trim(Cmd)) = 'q' then Break;  
   GotoXY(1, WhereY - 1); ClrEol;  
   end;  
   end;  
    
   Data.Free;  
   TextColor(LightCyan);  
   WriteLn;  
   WriteLn('-- End of file. Press Enter to return to menu --');  
   NormVideo;  
   ReadLn;  
  end;  
    
  // ---------------------------------------------------------  
  // Screen-width helper (columns in the current text window)  
  // ---------------------------------------------------------  
  function ScreenCols: Integer;  
  begin  
   ScreenCols := Lo(WindMax) + 1;  
  end;  
    
  // ---------------------------------------------------------  
  // Picks a 7-char type tag so descriptions line up  
  // ---------------------------------------------------------  
  function RowTag(const Item: TMenuItem): String;  
  begin  
   case Item.ItemType of  
   'i': RowTag := ' ';  
   '3': RowTag := ' [ERR] ';  
   '0': RowTag := ' [TXT] ';  
   '1': RowTag := ' [DIR] ';  
   '7': RowTag := ' [FND] ';  
   'h': RowTag := ' [WWW] ';  
   else  
   if Item.IsNavigable then RowTag := ' [BIN] '  
   else RowTag := ' [?] ';  
   end;  
  end;  
    
  // ---------------------------------------------------------  
  // Clamps/pads a row to the screen width: stops long lines  
  // from wrapping and lets the highlight span the whole row  
  // ---------------------------------------------------------  
  function RowText(const Prefix, Desc: String): String;  
  var  
   W: Integer;  
  begin  
   W := ScreenCols - 1; { leave the last column free to avoid auto-wrap }  
   RowText := Prefix + Desc;  
   if Length(RowText) > W then  
   RowText := Copy(RowText, 1, W)  
   else  
   RowText := RowText + StringOfChar(' ', W - Length(RowText));  
  end;  
    
  // ---------------------------------------------------------  
  // Downloads a binary item to disk (images, sounds, etc.)  
  // ---------------------------------------------------------  
  procedure SaveFile(const Item: TMenuItem);  
  var  
   Data: TMemoryStream;  
   HomeDir, FileName, BaseName, Answer: String;  
  begin  
   BaseName := ExtractFileName(Item.Selector);  
   if Trim(BaseName) = '' then BaseName := 'gopherfile';  
    
   GotoXY(1, WindMaxY);  
   ClrEol;  
   TextColor(LightGreen);  
   Write('Save as [', BaseName, ']: ');  
   NormVideo;  
   ReadLn(Answer);  
   if Trim(Answer) <> '' then FileName := Trim(Answer) else FileName :=  
  BaseName;  
    
   HomeDir := GetEnvironmentVariable('HOME');  
   if HomeDir <> '' then FileName := HomeDir + PathDelim + FileName;  
    
   ClrScr;  
   WriteLn('Downloading ', Item.Selector, ' from ', Item.Host, ' ...');  
   Data := FetchRaw(Item.Host, Item.Port, Item.Selector);  
   try  
   if Data.Size > 0 then  
   begin  
   try  
   Data.SaveToFile(FileName);  
   TextColor(LightGreen);  
   WriteLn('Saved ', Data.Size, ' bytes to ', FileName);  
   except  
   on E: Exception do  
   begin  
   TextColor(LightRed);  
   WriteLn('Error saving file: ', E.Message);  
   end;  
   end;  
   end  
   else  
   begin  
   TextColor(LightRed);  
   WriteLn('No data received (empty file or connection error).');  
   end;  
   NormVideo;  
   finally  
   Data.Free;  
   end;  
    
   TextColor(LightCyan);  
   WriteLn('-- Press Enter to return to menu --');  
   NormVideo;  
   ReadLn;  
  end;  
    
  // ---------------------------------------------------------  
  // Shell-escapes a string for safe use inside single quotes  
  // ---------------------------------------------------------  
  function ShellSingleQuote(const S: String): String;  
  const  
   SQ = ''''; { one single-quote character }  
  begin  
   ShellSingleQuote := SQ + StringReplace(S, SQ, SQ + '\\' + SQ + SQ,  
  [rfReplaceAll]) + SQ;  
  end;  
    
  // ---------------------------------------------------------  
  // Opens a Gopher 'h' (HTML) link in the system browser  
  // ---------------------------------------------------------  
  procedure OpenURL(const RawSelector: String);  
  var  
   URL: String;  
  begin  
   URL := RawSelector;  
   { 'h' items almost always carry "URL:" in the selector. }  
   if (Length(URL) >= 4) and (LowerCase(Copy(URL, 1, 4)) = 'url:') then  
   URL := Copy(URL, 5, Length(URL) - 4);  
   URL := Trim(URL);  
    
   GotoXY(1, WindMaxY);  
   ClrEol;  
   if URL = '' then  
   begin  
   TextColor(LightRed);  
   Write('This link has no URL to open.');  
   NormVideo;  
   Delay(1200);  
   Exit;  
   end;  
    
   { Launch detached and injection-safe: the URL is single-quoted with any  
   embedded quotes escaped, output is discarded, and '&' backgrounds it so  
   the browser opens without freezing the TUI or leaving a zombie. }  
   FpSystem('xdg-open ' + ShellSingleQuote(URL) + ' >/dev/null 2>&1 &');  
    
   TextColor(LightGreen);  
   Write(Copy('Opening in browser: ' + URL, 1, ScreenCols - 1));  
   NormVideo;  
   Delay(1000);  
  end;  
    
  // ---------------------------------------------------------  
  // Renders the viewport based on current scroll position  
  // ---------------------------------------------------------  
  procedure DrawMenu(ViewCapacity: Integer);  
  var  
   i, TargetMenuIdx: Integer;  
  begin  
   if Length(NavMap) > 0 then  
   TargetMenuIdx := NavMap[ActiveNavIndex]  
   else  
   TargetMenuIdx := -1;  
    
   // Auto-scroll the camera if the selection goes out of bounds  
   if TargetMenuIdx <> -1 then  
   begin  
   if TargetMenuIdx < ScrollOffset then  
   ScrollOffset := TargetMenuIdx;  
   if TargetMenuIdx >= ScrollOffset + ViewCapacity then  
   ScrollOffset := TargetMenuIdx - ViewCapacity + 1;  
   end;  
    
   ClrScr;  
   TextColor(LightGreen);  
   WriteLn('=======================================================');  
   WriteLn(' Gopherspace: ', CurrentHost, ':', CurrentPort);  
   WriteLn('=======================================================');  
   NormVideo;  
    
   // Render only the items that fit on screen  
   for i := ScrollOffset to ScrollOffset + ViewCapacity - 1 do  
   begin  
   if i > High(MenuItems) then Break;  
    
   // Apply Highlight  
   if i = TargetMenuIdx then  
   begin  
   TextBackground(LightGray);  
   TextColor(Black);  
   end  
   else  
   begin  
   TextBackground(Black);  
   TextColor(LightGray);  
   end;  
    
   WriteLn(RowText(RowTag(MenuItems[i]), MenuItems[i].Desc));  
    
   NormVideo;  
   end;  
    
   // Footer  
   GotoXY(1, WindMaxY);  
   TextColor(LightGreen);  
   Write(Copy('Arrows/PgUp/PgDn: Move | Enter: Select | G: Go | B: Back | Q:  
  Quit', 1, ScreenCols - 1));  
   NormVideo;  
  end;  
    
  // ---------------------------------------------------------  
  // Parses a gopher URL or bare host into a TMenuItem  
  // gopher://host:port/Tselector (T = one item-type char)  
  // ---------------------------------------------------------  
  function ParseGopherURL(Raw: String): TMenuItem;  
  var  
   P: Integer;  
   HostPort, PathPart: String;  
  begin  
   Result.ItemType := '1';  
   Result.Desc := '';  
   Result.Selector := '';  
   Result.Host := '';  
   Result.Port := 70;  
   Result.IsNavigable := True;  
    
   Raw := Trim(Raw);  
   if Raw = '' then Exit;  
    
   { Strip a leading gopher:// scheme if present. }  
   if LowerCase(Copy(Raw, 1, 9)) = 'gopher://' then  
   Delete(Raw, 1, 9);  
    
   { Split host[:port] from the path at the first '/'. }  
   P := Pos('/', Raw);  
   if P > 0 then  
   begin  
   HostPort := Copy(Raw, 1, P - 1);  
   PathPart := Copy(Raw, P + 1, Length(Raw));  
   end  
   else  
   begin  
   HostPort := Raw;  
   PathPart := '';  
   end;  
    
   { Optional :port on the host. }  
   P := Pos(':', HostPort);  
   if P > 0 then  
   begin  
   Result.Host := Copy(HostPort, 1, P - 1);  
   Result.Port := StrToIntDef(Copy(HostPort, P + 1, Length(HostPort)), 70);  
   end  
   else  
   Result.Host := HostPort;  
    
   { First path char is the gopher item type; the rest is the selector. }  
   if PathPart <> '' then  
   begin  
   Result.ItemType := PathPart[1];  
   Result.Selector := Copy(PathPart, 2, Length(PathPart));  
   end;  
  end;  
    
  // ---------------------------------------------------------  
  // Prompts for a URL/host, parses it, and acts on it the  
  // same way Enter does. Returns True if a new menu should  
  // be fetched by the main loop.  
  // ---------------------------------------------------------  
  function GoToAddress: Boolean;  
  var  
   Input, Query: String;  
   Item: TMenuItem;  
    
   procedure PushHistory;  
   begin  
   SetLength(History, Length(History) + 1);  
   History[High(History)].Host := CurrentHost;  
   History[High(History)].Port := CurrentPort;  
   History[High(History)].Selector := CurrentSelector;  
   end;  
    
  begin  
   Result := False;  
    
   GotoXY(1, WindMaxY);  
   ClrEol;  
   TextColor(LightCyan);  
   Write('Go to (host or gopher URL): ');  
   NormVideo;  
   ReadLn(Input);  
    
   Input := Trim(Input);  
   if Input = '' then Exit;  
    
   Item := ParseGopherURL(Input);  
   if Item.Host = '' then Exit;  
    
   case Item.ItemType of  
   '0':  
   ShowText(Item.Host, Item.Port, Item.Selector);  
   'h':  
   OpenURL(Item.Selector);  
   '7':  
   begin  
   GotoXY(1, WindMaxY);  
   ClrEol;  
   TextColor(LightGreen);  
   Write('Enter search query: ');  
   NormVideo;  
   ReadLn(Query);  
   if Trim(Query) <> '' then  
   begin  
   PushHistory;  
   CurrentHost := Item.Host;  
   CurrentPort := Item.Port;  
   CurrentSelector := Item.Selector + #9 + Trim(Query);  
   Result := True;  
   end;  
   end;  
   '4', '5', '6', '9', 'g', 'I', 'p', 's', 'd', ';':  
   SaveFile(Item);  
   else  
   begin  
   PushHistory;  
   CurrentHost := Item.Host;  
   CurrentPort := Item.Port;  
   CurrentSelector := Item.Selector;  
   Result := True;  
   end;  
   end;  
  end;  
    
  // ---------------------------------------------------------  
  // Main execution loop  
  // ---------------------------------------------------------  
  var  
   MenuData: TStringList;  
   i, ViewCapacity: Integer;  
   NeedFetch: Boolean;  
   Key: Char;  
  begin  
   NeedFetch := True;  
   History := nil;  
    
   while True do  
   begin  
   // Fetch data only when we've navigated to a new menu  
   if NeedFetch then  
   begin  
   ClrScr;  
   WriteLn('Fetching menu from ', CurrentHost, '...');  
   MenuData := FetchData(CurrentHost, CurrentPort, CurrentSelector);  
    
   SetLength(MenuItems, 0);  
   SetLength(NavMap, 0);  
    
   for i := 0 to MenuData.Count - 1 do  
   begin  
   if (MenuData[i] = '.') or (MenuData[i] = '') then Continue;  
    
   SetLength(MenuItems, Length(MenuItems) + 1);  
   MenuItems[High(MenuItems)] := ParseMenuLine(MenuData[i]);  
    
   if MenuItems[High(MenuItems)].IsNavigable then  
   begin  
   SetLength(NavMap, Length(NavMap) + 1);  
   NavMap[High(NavMap)] := High(MenuItems);  
   end;  
   end;  
   MenuData.Free;  
    
   ActiveNavIndex := 0;  
   ScrollOffset := 0;  
   NeedFetch := False;  
   end;  
    
   // ViewCapacity is screen height minus header(3 lines) and footer(1  
  line)  
   ViewCapacity := WindMaxY - 4;  
   DrawMenu(ViewCapacity);  
    
   // Input Handling  
   Key := ReadKey;  
    
   if Key = #0 then // Extended keys (Arrows)  
   begin  
   Key := ReadKey;  
   if Length(NavMap) = 0 then Continue; // Ignore arrows if nothing is  
  selectable  
    
   case Key of  
   #72: if ActiveNavIndex > 0 then Dec(ActiveNavIndex); // Up Arrow  
   #80: if ActiveNavIndex < High(NavMap) then Inc(ActiveNavIndex); //  
  Down Arrow  
   #73: begin // Page Up  
   ActiveNavIndex := ActiveNavIndex - ViewCapacity;  
   if ActiveNavIndex < 0 then ActiveNavIndex := 0;  
   end;  
   #81: begin // Page Down  
   ActiveNavIndex := ActiveNavIndex + ViewCapacity;  
   if ActiveNavIndex > High(NavMap) then ActiveNavIndex :=  
  High(NavMap);  
   end;  
   end;  
   end  
   else  
   begin  
   case LowerCase(Key) of  
   'q': Break;  
   'b': begin  
   if Length(History) > 0 then  
   begin  
   CurrentHost := History[High(History)].Host;  
   CurrentPort := History[High(History)].Port;  
   CurrentSelector := History[High(History)].Selector;  
   SetLength(History, Length(History) - 1);  
   NeedFetch := True;  
   end;  
   end;  
   'g': if GoToAddress then NeedFetch := True;  
   #13: begin // Enter Key  
   if Length(NavMap) > 0 then  
   begin  
   TargetMenu := MenuItems[NavMap[ActiveNavIndex]];  
    
   if TargetMenu.ItemType = '1' then  
   begin  
   SetLength(History, Length(History) + 1);  
   History[High(History)].Host := CurrentHost;  
   History[High(History)].Port := CurrentPort;  
   History[High(History)].Selector := CurrentSelector;  
    
   CurrentHost := TargetMenu.Host;  
   CurrentPort := TargetMenu.Port;  
   CurrentSelector := TargetMenu.Selector;  
   NeedFetch := True;  
   end  
   else if TargetMenu.ItemType = '0' then  
   begin  
   ShowText(TargetMenu.Host, TargetMenu.Port, TargetMenu.  
  Selector);  
   end  
   else if TargetMenu.ItemType = '7' then  
   begin  
   // Briefly take over the bottom row for standard text  
  input  
   GotoXY(1, WindMaxY);  
   ClrEol;  
   TextColor(LightGreen);  
   Write('Enter search query: ');  
   NormVideo;  
   ReadLn(SearchQuery);  
   if Trim(SearchQuery) <> '' then  
   begin  
   SetLength(History, Length(History) + 1);  
   History[High(History)].Host := CurrentHost;  
   History[High(History)].Port := CurrentPort;  
   History[High(History)].Selector := CurrentSelector;  
    
   CurrentHost := TargetMenu.Host;  
   CurrentPort := TargetMenu.Port;  
   CurrentSelector := TargetMenu.Selector + #9 +  
  SearchQuery;  
   NeedFetch := True;  
   end;  
   end  
   else if TargetMenu.ItemType = 'h' then  
   OpenURL(TargetMenu.Selector)  
   else  
   SaveFile(TargetMenu);  
   end;  
   end;  
   end;  
   end;  
   end;  
  end.  
    
   ----- gophernav.pas ends -----  
    
  --- msged/lnx 6.3 archlinux-git  
   * Origin: 1:229/452 (1:229/452@fidonet)  
     

[ list messages | list forums | previous ]

352,367 visits
(c) 1994,  bbs@darkrealms.ca