import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; enum DocumentType { /*Canonical types RFC1436 line 548*/ /// Item is a file (0) TextFile("0"), /// Item is a directory (1) Directory("1"), /// Item is a CSO phone-book server (2) CSO("2"), /// Error returned by server (3) ErrorCode("3"), /// Item is a BinHexed Macintosh file. (4) BinHex("4"), /// Item is DOS binary archive of some sort. (5) DosBinary("5"), /// Item is a UNIX uuencoded file. (6) Uuencoded("6"), /// Item is an Index-Search server. (7) Search("7"), /// Item points to a text-based telnet session. (8) Telnet("8"), /// Item is a binary file! (9) Binary("9"), /// It em is a redundant server (*) MirrorOrAlternate("*"), /// Item points to a text-based tn3270 session. (T) Telnet3270("T"), /// Item is a GIF format graphics file. (g) Gif("g"), /// Item is some kind of image file. Client decides how to display. (I) Image("I"), /**** Gopher+ ****/ /// Bitmap image (:) Bitmap(":"), /// Movie file (;) Movie(";"), /// Sound file (<) Soundfile("<"), /*non-canonical types*/ /// Doc. Used for .doc and .pdf (d) Doc("d"), /// HTML file (h) Html("h"), /// Informational message, widely used (i) Informational("i"), /// Usually png image (p) PngImage("p"), /// Rich text document (r) RichTextDocument("r"), /// usually WAV (s) SoundFile("s"), /// Pdf (P) Pdf("P"), /// XML file (X) Xml("X"), /// Document type isn't any of the known types Unknown("?"); const DocumentType(this.code); final String code; /// Returns the canonical name of the document type String get typeName => name; /// Returns character code used for this document type String get typeCode => code; } class ParsedGopherItem { static final docMap = {for (var item in DocumentType.values) item.code: item}; Uri url; String? name; DocumentType documentType; ParsedGopherItem._(this.url, this.name, this.documentType); /// Parse gopher item from url, from url file type is not known /// so it is guessed by file extension // TODO: Do actuall filetype guessing static ParsedGopherItem parseUrl(String url) { if (!url.startsWith("gopher://")) url = "gopher://$url"; var uri = Uri.parse(url); // We expect TextFile by default unless we think it's menu final doctype = (uri.path == "" || uri.path.endsWith("/")) ? DocumentType.Directory : DocumentType.TextFile; if (uri.port == 0) uri = uri.replace(port: 70); return ParsedGopherItem._(uri, "", doctype); } /// Parses single gopher item from single menu line static ParsedGopherItem? parseMenuLine(String menuLine) { // If theres not even the item type and name we won't bother if (menuLine.length < 2) return null; var parts = menuLine.split("\t"); if (parts.length != 4) return null; // If string is correct, we should have 4 parts // 0Pony Prices Prices/Ponies pserver.bookstore.umn.edu 70 //type;name ; selektor ; host ; port // 0;Pony Prices; Prices/Ponies; pserver.bookstore.umn.edu; 70 var uri = Uri( scheme: "gopher", path: parts[1], host: parts[2], port: int.tryParse(parts[3]) ?? 70, ); String type = menuLine[0]; String name = parts[0].substring(1); final docType = docMap.containsKey(type) ? docMap[type]! : docMap["?"]!; return ParsedGopherItem._(uri, name, docType); } /// Reads gopher directory/menu from host:port and optionally selector static Future loadMenu(String host, int port, {String selector = ""}) async { final Socket sock = await Socket.connect(host, port); sock.write("$selector\r\n"); await sock.flush(); print("Entered load menu"); final ret = []; final List rawRet = []; final subscription = sock.listen( (Uint8List data) { rawRet.add(data); String rcvd = utf8.decode(data, allowMalformed: true); ParsedGopherItem? apend; // TODO: This is flawed, it usually works but it's just a matter of time before it breaks // If \n is in the next buffer, the part before \n gets cutoff // resulting in unparsable line // the substrining must begin outside the loop listen call // or it must be rewritten using LineSplitter() int start = 0; for (int i = 0; i < rcvd.length; i++) { if (rcvd[i] == "\n") { var menuLine = rcvd.substring(start, i); if (menuLine.startsWith(".")) { print("Received starting dot, this should be the end I guess"); return; } apend = parseMenuLine(menuLine); if (apend != null) ret.add(apend); start = i + 1; } } }); await subscription.asFuture(); sock.close(); return LoadedGopherItem( ParsedGopherItem._(Uri.parse("gopher://$host:$port$selector"), selector, DocumentType.Directory), asMenu: ret, data: rawRet, ); } Future loadItem() async { final Socket sock = await Socket.connect(url.host, url.port); print("Sending request to ${url.host}[:${url.port}] => '${url.path}'"); sock.write("${url.path}\r\n"); var data = await sock.toList(); print("Closing: ${sock.close()} as type ${this.documentType}"); return LoadedGopherItem(this, data: data); } Future load({bool? forceMenu}) async { if ((documentType == DocumentType.Directory) || (forceMenu ?? false)) { print("Forcing? ($forceMenu) reload as menu for: $url"); return loadMenu(url.host, url.port, selector: url.path); } return loadItem(); } } class LoadedGopherItem { final ParsedGopherItem parsed; final List? data; final List? asMenu; LoadedGopherItem(this.parsed, {this.data, this.asMenu}); }