Skip to content

Commit

Permalink
Use datasworn: scheme for Datasworn ID links
Browse files Browse the repository at this point in the history
Fixes #383
  • Loading branch information
cwegrzyn committed Jul 17, 2024
1 parent 7990f61 commit e5df399
Show file tree
Hide file tree
Showing 10 changed files with 128 additions and 35 deletions.
2 changes: 1 addition & 1 deletion data/starforged.supplement.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ _id: starforgedsupp
datasworn_version: 0.1.0
ruleset: starforged
title: "Iron Vault support for Ironsworn: Starforged"
description: Collection of utility oracles for use with Starforged
description: Collection of utility oracles and assets for use with Starforged
authors:
- name: Iron Vault Dev Team
date: "2024-07-16"
Expand Down
60 changes: 55 additions & 5 deletions src/datastore/parsers/datasworn/id.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,65 @@
import { extractDataswornLinkParts } from "./id";
import {
extractDataswornLinkParts,
matchDataswornLink,
ParsedDataswornId,
} from "./id";

describe("extractDataswornLinkParts", () => {
it.each([
["asset:starforged/path/empath", ["asset", "starforged/path/empath"]],
const VALID_TEST_CASES: [string, ParsedDataswornId][] = [
[
"asset:starforged/path/empath",
{
kind: "asset",
path: "starforged/path/empath",
id: "asset:starforged/path/empath",
},
],
[
"asset.ability.move:starforged/path/empath.0.read_heart",
["asset.ability.move", "starforged/path/empath.0.read_heart"],
{
kind: "asset.ability.move",
path: "starforged/path/empath.0.read_heart",
id: "asset.ability.move:starforged/path/empath.0.read_heart",
},
],
];

it.each(VALID_TEST_CASES)("matches old-style link '%s'", (link, result) => {
expect(extractDataswornLinkParts(link)).toEqual(result);
});

it.each(VALID_TEST_CASES)(
"matches new-style link 'datasworn:%s'",
(link, result) => {
expect(extractDataswornLinkParts("datasworn:" + link)).toEqual(result);
},
);

it.each([
["http://asdf", null],
["./foo", null],
])("should properly handle '%s'", (link, result) => {
])("returns null for '%s'", (link, result) => {
expect(extractDataswornLinkParts(link)).toEqual(result);
});
});

describe("matchDataswornLink", () => {
it.each`
text | result
${"[Foo](datasworn:asset:starforged/path/empath)"} | ${{ label: "Foo", id: "asset:starforged/path/empath" }}
${"[Foo With Spaces -](asset:starforged/path/empath)"} | ${{ label: "Foo With Spaces -", id: "asset:starforged/path/empath" }}
${"[Foo](https://foo)"} | ${null}
${"datasworn:asset:starforged/path/empath"} | ${null}
`(
"should handle '%s'",
({
text,
result,
}: {
text: string;
result: ReturnType<typeof matchDataswornLink>;
}) => {
expect(matchDataswornLink(text)).toEqual(result);
},
);
});
51 changes: 43 additions & 8 deletions src/datastore/parsers/datasworn/id.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
import { TypeId } from "@datasworn/core/dist/IdElements";

// TODO(@cwegrzyn): is there an official low complexity regex for datasworn ids?
export const DATASWORN_ID_REGEX = new RegExp(
String.raw`^((?:${TypeId.Primary.join("|")})(?:\.[-\w]+)*):(\w[-\w /\.]+)$`,
const DATASWORN_ID_REGEX_NO_ANCHOR = new RegExp(
String.raw`((?:${TypeId.Primary.join("|")})(?:\.[-\w]+)*):(\w[-\w /\.]+)`,
);

export const DATASWORN_ID_REGEX_NO_ANCHOR = new RegExp(
String.raw`((?:${TypeId.Primary.join("|")})(?:\.[-\w]+)*):(\w[-\w /\.]+)`,
const DATASWORN_ID_REGEX = new RegExp(
String.raw`^(?:datasworn:)?${DATASWORN_ID_REGEX_NO_ANCHOR.source}$`,
);

export const DATASWORN_LINK_REGEX = new RegExp(
String.raw`\[(?<label>[^\]]*)\]\((?<uri>${DATASWORN_ID_REGEX_NO_ANCHOR.source})\)`,
const DATASWORN_LINK_REGEX = new RegExp(
String.raw`\[(?<label>[^\]]*)\]\((?:datasworn:)?(?<id>${DATASWORN_ID_REGEX_NO_ANCHOR.source})\)`,
);

export type ParsedDataswornId = {
id: string;
kind: string;
path: string;
};

/** Given a datasworn URI (e.g., in an href), extracts the kind and the path.
* Works with both old-style invalid URIs (w/o `datasworn:` prefix) and newer `datasworn:` prefixed ones
*/
export function extractDataswornLinkParts(
uri: string,
): [string, string] | null {
): ParsedDataswornId | null {
const result = uri.match(DATASWORN_ID_REGEX);
return result && [result[1], result[2]];
return (
result && {
id: `${result[1]}:${result[2]}`,
kind: result[1],
path: result[2],
}
);
}

/** Finds a markdown datasworn link and returns the text and URI.
* Works with both old-style invalid URIs (w/o `datasworn:` prefix) and newer `datasworn:` prefixed ones.
*/
export function matchDataswornLink(
text: string,
): { label: string; id: string } | null {
const match = text.match(DATASWORN_LINK_REGEX);
if (!match) return null;
return { label: match.groups!.label, id: match.groups!.id };
}

/** Render a markdown link for a given datasworn ID. */
export function createDataswornMarkdownLink(label: string, id: string): string {
if (id.startsWith("datasworn:"))
throw new Error(
`Unexpected 'datasworn:' prefix when generating link for '${id}'`,
);
return `[${label}](datasworn:${id})`;
}
4 changes: 2 additions & 2 deletions src/entity/command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractDataswornLinkParts } from "datastore/parsers/datasworn/id";
import Handlebars from "handlebars";
import { createOrAppendMechanics } from "mechanics/editor";
import { createOracleGroup } from "mechanics/node-builders";
Expand Down Expand Up @@ -25,7 +26,6 @@ import {
EntityResults,
EntitySpec,
} from "./specs";
import { extractDataswornLinkParts } from "datastore/parsers/datasworn/id";

type OraclePromptOption =
| { action: "pick"; row: OracleRollableRow }
Expand Down Expand Up @@ -141,7 +141,7 @@ export async function generateEntityCommand(
(match, el) => {
const collId = match.item[1].collectionId;
if (collId) {
const path = extractDataswornLinkParts(collId)![1];
const path = extractDataswornLinkParts(collId)!.path;
const [rulesetId] = path.split("/");
const ruleset = plugin.datastore.rulesPackages.get(rulesetId);
if (ruleset) {
Expand Down
6 changes: 3 additions & 3 deletions src/entity/modal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DATASWORN_LINK_REGEX } from "datastore/parsers/datasworn/id";
import { matchDataswornLink } from "datastore/parsers/datasworn/id";
import {
App,
ButtonComponent,
Expand Down Expand Up @@ -42,9 +42,9 @@ function evaluateAttribute(
);
return rawResult.replaceAll(/\s+/g, "_").toLowerCase();
case AttributeMechanism.ParseId: {
const match = rawResult.match(DATASWORN_LINK_REGEX);
const match = matchDataswornLink(rawResult);
if (!match) throw new Error(`no id link found: ${rawResult}`);
const parts = match.groups!.uri.split("/");
const parts = match.id.split("/");
if (parts.length < 2) throw new Error(`no / separator in ${rawResult}`);
return parts.last()!;
}
Expand Down
9 changes: 6 additions & 3 deletions src/link-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,25 @@ export default function installLinkHandler(plugin: IronVaultPlugin) {
const linkText = text?.toLowerCase();
const dataswornLinkCandidate =
linkText && extractDataswornLinkParts(linkText);
logger.trace("findEntry: %s -> %o", linkText, dataswornLinkCandidate);
if (!dataswornLinkCandidate) return undefined;

// First, try to find the entry by ID
// TODO(@cwegrzyn): should use campaign context when ready? at the very least, should filter to enabled vs indexed?
const entry = plugin.datastore.indexer.prioritized.get(linkText);
const entry = plugin.datastore.indexer.prioritized.get(
dataswornLinkCandidate.id,
);
if (entry) return entry;

// Then, search by name in the major asset types
const entityType = dataswornLinkCandidate[0];
const entityType = dataswornLinkCandidate.kind;
if (entityType != "move" && entityType != "oracle" && entityType != "asset")
return undefined;

function normalize(s: string) {
return s.replaceAll(/\s*/g, "").toLowerCase();
}
const searchString = normalize(dataswornLinkCandidate[1]);
const searchString = normalize(dataswornLinkCandidate.path);
const index = plugin.datastore.indexer.prioritized.ofKind(entityType);
for (const entry of index.values()) {
if (normalize(entry.value.name) == searchString) return entry;
Expand Down
8 changes: 6 additions & 2 deletions src/mechanics/node-builders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Clock } from "clocks/clock";
import { ClockFileAdapter } from "clocks/clock-file";
import { createDataswornMarkdownLink } from "datastore/parsers/datasworn/id";
import * as kdl from "kdljs";
import { Document, Node } from "kdljs";
import { RollWrapper } from "model/rolls";
Expand Down Expand Up @@ -89,7 +90,10 @@ export function createOracleNode(
): kdl.Node {
const props: { name: string; roll: number; result: string; cursed?: number } =
{
name: `[${name ?? oracleNameWithParents(roll.oracle)}](${roll.oracle.id})`,
name: createDataswornMarkdownLink(
name ?? oracleNameWithParents(roll.oracle),
roll.oracle.id,
),
// TODO: this is preposterous
roll: roll.roll.roll,
result: roll.ownResult,
Expand Down Expand Up @@ -171,7 +175,7 @@ export function generateMechanicsNode(move: MoveDescription): Document {
}

function generateMoveLink(move: MoveDescription): string {
return move.id ? `[${move.name}](${move.id})` : move.name;
return move.id ? createDataswornMarkdownLink(move.name, move.id) : move.name;
}

export function createOracleGroup(
Expand Down
7 changes: 2 additions & 5 deletions src/mechanics/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DATASWORN_LINK_REGEX } from "datastore/parsers/datasworn/id";
import { matchDataswornLink } from "datastore/parsers/datasworn/id";
import * as kdl from "kdljs";

export function isMoveNode(
Expand All @@ -21,10 +21,7 @@ export function getMoveIdFromNode(node: kdl.Node): string | undefined {
if (node.properties["id"] && typeof node.properties["id"] == "string")
return node.properties["id"];
if (node.values.length > 0 && typeof node.values[0] == "string") {
const link = node.values[0].match(DATASWORN_LINK_REGEX);
if (link) {
return link.groups?.uri;
}
return matchDataswornLink(node.values[0])?.id;
}

return undefined;
Expand Down
10 changes: 6 additions & 4 deletions src/migrate/migration-0_0_10-0_1_0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ import { hasOldId, replaceIds, replaceLinks } from "./migration-0_0_10-0_1_0";
const TEST_CASES: { str: string; result: string; skipLink?: boolean }[] = [
{
str: "[test](id:classic/oracles/turning_point/combat_action)",
result: "[test](oracle_rollable:classic/turning_point/combat_action)",
result:
"[test](datasworn:oracle_rollable:classic/turning_point/combat_action)",
},
{
str: "[Character Oracles \\/ Character Name \\/ Given Name](oracle:starforged\\/oracles\\/characters\\/name\\/given)",
result:
"[Character Oracles \\/ Character Name \\/ Given Name](oracle_rollable:starforged\\/character\\/name\\/given_name)",
"[Character Oracles \\/ Character Name \\/ Given Name](datasworn:oracle_rollable:starforged\\/character\\/name\\/given_name)",
},
{
str: "[Face Danger](move:starforged\\/moves\\/adventure\\/face_danger)",
result: "[Face Danger](move:starforged\\/adventure\\/face_danger)",
result:
"[Face Danger](datasworn:move:starforged\\/adventure\\/face_danger)",
},
{
str: "[Foo \\/ Bar \\n](oracle:starforged\\/oracles\\/characters\\/name\\/callsign)",
result:
"[Foo \\/ Bar \\n](oracle_rollable:starforged\\/character\\/name\\/callsign)",
"[Foo \\/ Bar \\n](datasworn:oracle_rollable:starforged\\/character\\/name\\/callsign)",
},
{
str: "- id: starforged/assets/command_vehicle/starship",
Expand Down
6 changes: 4 additions & 2 deletions src/migrate/migration-0_0_10-0_1_0.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDataswornMarkdownLink } from "datastore/parsers/datasworn/id";
import idMapRaw from "../../data/datasworn-0.0.10-to-0.1.0-id_map.json" assert { type: "json" };

const ID_REGEX = /(?<id>[-\w._]+(?:(?:\/|\\\/)[-\w._]+)+)/g;
Expand Down Expand Up @@ -36,7 +37,8 @@ export function replaceIds(
input: string,
log?: { offset: number; length: number; newId: string }[],
): string {
return input.replaceAll(
// First, replace links, to handle datasworn link syntax. Then, replace everything else.
return replaceLinks(input).replaceAll(
ID_OPTIONAL_KIND_REGEX,
(orig, kind: string, id: string, offset: number) => {
const newId = getNewId(id);
Expand All @@ -53,7 +55,7 @@ export function replaceLinks(input: string): string {
LINK_REGEX,
(orig, label: string, kind: string, id: string) => {
const newId = getNewId(id);
return newId ? `[${label}](${newId})` : orig;
return newId ? createDataswornMarkdownLink(label, newId) : orig;
},
);
}

0 comments on commit e5df399

Please sign in to comment.