diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts new file mode 100644 index 0000000..44b3314 --- /dev/null +++ b/src/parser/parser.test.ts @@ -0,0 +1,82 @@ +import { + parseLink, + parsePermalink, + parseArgs, + verifiers, + discriminate, + toURI, +} from "./parser"; +import { LinkDiscriminator } from "./types"; + +const curriedDiscriminate = (id: string) => + discriminate(id, verifiers, LinkDiscriminator.ParseFailed); + +it("types identifiers correctly", () => { + expect(curriedDiscriminate("@user:matrix.org")).toEqual( + LinkDiscriminator.UserId + ); + expect(curriedDiscriminate("!room:matrix.org")).toEqual( + LinkDiscriminator.RoomId + ); + expect( + curriedDiscriminate("!somewhere:example.org/$event:example.org") + ).toEqual(LinkDiscriminator.Permalink); + expect(curriedDiscriminate("+group:matrix.org")).toEqual( + LinkDiscriminator.GroupId + ); + expect(curriedDiscriminate("#alias:matrix.org")).toEqual( + LinkDiscriminator.Alias + ); +}); + +it("types garbadge as such", () => { + expect(curriedDiscriminate("sdfa;fdlkja")).toEqual( + LinkDiscriminator.ParseFailed + ); + expect(curriedDiscriminate("$event$matrix.org")).toEqual( + LinkDiscriminator.ParseFailed + ); + expect(curriedDiscriminate("/user:matrix.org")).toEqual( + LinkDiscriminator.ParseFailed + ); +}); + +it("parses vias", () => { + expect( + parseArgs("via=example.org&via=alt.example.org") + ).toHaveProperty("vias", ["example.org", "alt.example.org"]); +}); + +it("parses sharer", () => { + expect(parseArgs("sharer=blah")).toHaveProperty("sharer", "blah"); +}); + +it("parses random args", () => { + expect(parseArgs("via=qreqrqwer&banter=2342")).toHaveProperty( + "extras.banter", + ["2342"] + ); +}); + +it("parses permalinks", () => { + expect(parsePermalink("!somewhere:example.org/$event:example.org")).toEqual({ + roomKind: LinkDiscriminator.RoomId, + roomLink: "!somewhere:example.org", + eventId: "$event:example.org", + }); +}); + +it("formats links correctly", () => { + const bigLink = + "!somewhere:example.org/$event:example.org?via=dfasdf&via=jfjafjaf&uselesstag=useless"; + const host = "matrix.org"; + const prefix = host + "/#/"; + const parse = parseLink(bigLink); + + switch (parse.kind) { + case LinkDiscriminator.ParseFailed: + fail("Parse failed"); + default: + expect(toURI(host, parse)).toEqual(prefix + bigLink); + } +}); diff --git a/src/parser/parser.ts b/src/parser/parser.ts new file mode 100644 index 0000000..d00fb58 --- /dev/null +++ b/src/parser/parser.ts @@ -0,0 +1,175 @@ +import _ from "lodash"; + +import { + LinkDiscriminator, + SafeLink, + Link, + LinkContent, + Arguments, +} from "./types"; + +/* + * Verifiers are regexes which will match valid + * identifiers to their type + */ +type Verifier = [RegExp, A]; +export const roomVerifiers: Verifier< + LinkDiscriminator.Alias | LinkDiscriminator.RoomId +>[] = [ + [/^#([^\/:]+?):(.+)$/, LinkDiscriminator.Alias], + [/^!([^\/:]+?):(.+)$/, LinkDiscriminator.RoomId], +]; +export const verifiers: Verifier[] = [ + [/^[\!#]([^\/:]+?):(.+?)\/\$([^\/:]+?):(.+?)$/, LinkDiscriminator.Permalink], + [/^@([^\/:]+?):(.+)$/, LinkDiscriminator.UserId], + [/^\+([^\/:]+?):(.+)$/, LinkDiscriminator.GroupId], + ...roomVerifiers, +]; + +/* + * parseLink takes a striped hash link (without the '#/' prefix) + * and parses into a Link. If the parse failed the result will + * be ParseFailed + */ +export function parseLink(link: string): Link { + const [identifier, args] = link.split("?"); + + const kind = discriminate( + identifier, + verifiers, + LinkDiscriminator.ParseFailed + ); + const { vias, sharer, extras } = parseArgs(args); + + let parsedLink: LinkContent = { + identifier, + arguments: { + vias, + sharer, + extras, + }, + originalLink: link, + }; + + if (kind === LinkDiscriminator.Permalink) { + const { roomKind, roomLink, eventId } = parsePermalink(identifier); + + return { + kind, + ...parsedLink, + roomKind, + roomLink, + eventId, + }; + } + + return { + kind, + ...parsedLink, + }; +} + +/* + * Parses a permalink. + * Assumes the permalink is correct. + */ +export function parsePermalink(identifier: string) { + const [roomLink, eventId] = identifier.split("/"); + const roomKind = discriminate( + roomLink, + roomVerifiers, + // This is hacky but we're assuming identifier is a valid permalink + LinkDiscriminator.Alias + ); + + return { + roomKind, + roomLink, + eventId, + }; +} + +/* + * descriminate applies the verifiers to the identifier and + * returns it's type + */ +export function discriminate( + identifier: string, + verifiers: Verifier[], + fail: F +): T | F { + if (identifier !== encodeURI(identifier)) { + return fail; + } + + return verifiers.reduce((discriminator, verifier) => { + if (discriminator !== fail) { + return discriminator; + } + + if (identifier.match(verifier[0])) { + return verifier[1]; + } + + return discriminator; + }, fail); +} + +/* + * parseArgs parses the part of matrix.to links + */ +export function parseArgs(args: string): Arguments { + const parsedArgTuples = _.groupBy( + args + .split("&") + .map((x) => x.split("=")) + .filter((x) => x.length == 2), + (arg) => { + return arg[0]; + } + ); + + const parsedArgs = _.mapValues(parsedArgTuples, (arg) => + arg.map((x) => x[1]) + ); + + const { via, sharer, ...extras } = parsedArgs; + + return { + vias: via, + sharer: (parsedArgs.sharer || [undefined])[0], + extras, + }; +} + +/* + * toURI converts a parsed link to uri. Typically it's recommended + * to show the original link if it existed but this is handy in the + * case where this was constructed. + */ +export function toURI(hostname: string, link: SafeLink): string { + const cleanHostname = hostname.trim().replace(/\/+$/, ""); + switch (link.kind) { + case LinkDiscriminator.GroupId: + case LinkDiscriminator.UserId: + case LinkDiscriminator.RoomId: + case LinkDiscriminator.Alias: + case LinkDiscriminator.Permalink: + const uri = encodeURI(cleanHostname + "/#/" + link.identifier); + const vias = link.arguments.vias.map((s) => "via=" + s).join("&"); + const sharer = link.arguments.sharer + ? "sharer=" + link.arguments.sharer + : ""; + const extras = _.map(link.arguments.extras, (vals, key) => + vals.map((v) => key + "=" + v).join("&") + ).join("&"); + + const args = [vias, sharer, extras].filter(Boolean).join("&"); + + if (args) { + return uri + "?" + args; + } + + return uri; + } +} diff --git a/src/parser/types.ts b/src/parser/types.ts new file mode 100644 index 0000000..d765a55 --- /dev/null +++ b/src/parser/types.ts @@ -0,0 +1,55 @@ +import _ from "lodash"; + +export interface Arguments { + vias: string[]; + // Either one of the enums or a custom link + sharer: string; + extras: { [key: string]: string[] }; +} + +export interface LinkContent { + identifier: string; + arguments: Arguments; + originalLink: string; +} + +export enum LinkDiscriminator { + Alias = "ALIAS", + RoomId = "ROOM_ID", + UserId = "USER_ID", + Permalink = "PERMALINK", + GroupId = "GROUP_ID", + ParseFailed = "PARSE_FAILED", +} + +export interface Alias extends LinkContent { + kind: LinkDiscriminator.Alias; +} + +export interface RoomId extends LinkContent { + kind: LinkDiscriminator.RoomId; +} + +export interface UserId extends LinkContent { + kind: LinkDiscriminator.UserId; +} + +export interface GroupId extends LinkContent { + kind: LinkDiscriminator.GroupId; +} + +export interface Permalink extends LinkContent { + kind: LinkDiscriminator.Permalink; + roomKind: LinkDiscriminator.RoomId | LinkDiscriminator.Alias; + roomLink: string; + eventId: string; +} + +export interface ParseFailed { + kind: LinkDiscriminator.ParseFailed; + originalLink: string; +} + +export type SafeLink = Alias | RoomId | UserId | Permalink | GroupId; + +export type Link = SafeLink | ParseFailed;