GopherShy/lib/gopherlib.dart

218 lines
5.9 KiB
Dart

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<LoadedGopherItem> 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 = <ParsedGopherItem>[];
final List<Uint8List> rawRet = <Uint8List>[];
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<void>();
sock.close();
return LoadedGopherItem(
ParsedGopherItem._(Uri.parse("gopher://$host:$port$selector"), selector,
DocumentType.Directory),
asMenu: ret,
data: rawRet,
);
}
Future<LoadedGopherItem> 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<LoadedGopherItem> 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<Uint8List>? data;
final List<ParsedGopherItem>? asMenu;
LoadedGopherItem(this.parsed, {this.data, this.asMenu});
}