• Gopher client

    From Shawn Highfield@1:229/452 to All on Fri Jul 10 09:48:13 2026
    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:<real 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)