Skip to content

Commit

Permalink
#212 Make the tag parser return all the sheet metadata it can
Browse files Browse the repository at this point in the history
  • Loading branch information
braxtonhall committed Jan 12, 2023
1 parent e8be0b6 commit feccd0b
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 62 deletions.
55 changes: 27 additions & 28 deletions src/ts/content/adapters/tags/getTags.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import {makeCache} from "../../../common/util/cache";
import {TagSearchOptions, TagTrees, WarnedTag} from "./types";
import Sheets, {Range, ValueRange} from "../sheets";
import {parseTree} from "./parseTags";
import {TagSearchOptions, WarnedTag, TagSheetDescriptor, TagMapper, RawTagSheet, Tags} from "./types";
import Sheets, {Range, ValueRange, Values} from "../sheets";
import {parseTags} from "./parseTags";
import {incrementColumnBy} from "../sheets/util";
import {getSheetId} from "../../../common/entities/spreadsheet";

type TagMapper = `${string}$TAG${string}`;
type MappedRange = {range: Range; mapper: TagMapper; name: string; cwRange?: Range};

const META_TAG_SHEET = "Tag Index Index";
/**
* The Meta Tag Sheet stores information about where tags are in the spreadsheet
Expand All @@ -27,12 +24,12 @@ const META_TAG_SHEET = "Tag Index Index";
* The first tag in this table is at A2
*/

const {asyncCached, setCache} = makeCache<TagTrees>();
const {asyncCached, setCache} = makeCache<Tags>();

const rowIsRange = ([, topLeft, width]: string[]): boolean =>
topLeft && width && /^[A-Z]+[0-9]+$/.test(topLeft) && /^[0-9]+$/.test(width);

const rowToMappedRange = ([sheet, topLeft, width, cwColumn, userMapper, as]: string[]): MappedRange => {
const rowToSheetDescriptor = ([sheet, topLeft, width, cwColumn, userMapper, as]: string[]): TagSheetDescriptor => {
// `left` is a column, for example "B"
const left = topLeft.match(/^[A-Z]+/)[0];
// `top` is a row, for example "4"
Expand All @@ -46,49 +43,51 @@ const rowToMappedRange = ([sheet, topLeft, width, cwColumn, userMapper, as]: str
return {range, mapper, cwRange, name: as ?? sheet};
};

const getTagRanges = async (): Promise<MappedRange[]> => {
const getSheetDescriptors = async (): Promise<TagSheetDescriptor[]> => {
const range = Sheets.createRange(META_TAG_SHEET, "A", "E");
const response = await Sheets.readRanges(await getSheetId(), [range]);
return response?.[0].values.filter(rowIsRange).map(rowToMappedRange) ?? [];
return response?.[0].values.filter(rowIsRange).map(rowToSheetDescriptor) ?? [];
};

const extractRanges = (ranges: MappedRange[]): Set<Range> =>
const extractRanges = (ranges: TagSheetDescriptor[]): Set<Range> =>
ranges.reduce(
(acc, {cwRange, range}) => (cwRange ? acc.add(cwRange).add(range) : acc.add(range)),
new Set<Range>()
);

const nameResponses = (ranges: Range[], response: ValueRange[] | null): Map<Range, ValueRange> => {
const nameResponses = (ranges: Range[], response: ValueRange[] | null): Map<Range, Values> => {
const groupedResponses = new Map();
response?.forEach((valueRange, index) => groupedResponses.set(ranges[index], valueRange));
response?.forEach((valueRange, index) => groupedResponses.set(ranges[index], valueRange?.values));
return groupedResponses;
};

const annotateWithContentWarnings = (tags: string[][], contentWarnings: ValueRange | undefined): WarnedTag[][] =>
const annotateWithContentWarnings = (tags: string[][], contentWarnings: Values | undefined): WarnedTag[][] =>
tags.map((row, rowIndex) => row.map((cell) => ({tag: cell, warning: !!contentWarnings?.values?.[rowIndex]?.[0]})));

const toWarningTags = (namedResponses: Map<Range, ValueRange>) => (mappedRange: MappedRange) => {
const tags = namedResponses.get(mappedRange.range);
const mappedTags = mapTags(tags, mappedRange.mapper);
const contentWarnings = namedResponses.get(mappedRange.cwRange);
return annotateWithContentWarnings(mappedTags, contentWarnings);
};
const toTagSheet =
(namedResponses: Map<Range, Values>) =>
(descriptor: TagSheetDescriptor): RawTagSheet => {
const tags = namedResponses.get(descriptor.range);
const mappedTags = mapTags(tags, descriptor.mapper);
const contentWarnings = namedResponses.get(descriptor.cwRange);
const values = annotateWithContentWarnings(mappedTags, contentWarnings);
return {...descriptor, values};
};

const getSheetsTags = async (): Promise<WarnedTag[][]> => {
const mappedRanges = await getTagRanges();
const getSheetsTags = async (): Promise<RawTagSheet[]> => {
const mappedRanges = await getSheetDescriptors();
const ranges = [...extractRanges(mappedRanges)]; // We might have duplicate ranges, so we use a set when extracting
const response = await Sheets.readRanges(await getSheetId(), [...ranges]);
const response = await Sheets.readRanges(await getSheetId(), ranges);
const namedResponses = nameResponses(ranges, response);
return mappedRanges.flatMap(toWarningTags(namedResponses));
return mappedRanges.map(toTagSheet(namedResponses));
};

const mapTags = (valueRange: ValueRange | undefined, mapper: TagMapper): string[][] => {
const values = valueRange?.values ?? [];
return values.map((row) => row.map((value) => mapper.replaceAll("$TAG", value)));
const mapTags = (values: Values | undefined, mapper: TagMapper): string[][] => {
return values?.map((row) => row.map((value) => mapper.replaceAll("$TAG", value))) ?? [];
};

const getTagTrees = async ({noCache}: TagSearchOptions = {noCache: false}) => {
const implementation = async () => parseTree(await getSheetsTags());
const implementation = async () => parseTags(await getSheetsTags());
if (noCache) {
return implementation().then((tree) => setCache("", tree));
} else {
Expand Down
12 changes: 6 additions & 6 deletions src/ts/content/adapters/tags/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import {TagSearchOptions, TagTrees} from "./types";
import {TagSearchOptions, TagNodes, TagTree} from "./types";
import {getTagTrees} from "./getTags";

const getAncestry = async (tag: string): Promise<string[]> => {
const trees = await getTagTrees();
const {nodes} = await getTagTrees();
const ancestry = [];
for (let node = trees.get(tag.toLowerCase()); node; node = node.parent) {
ancestry.push(node.tag);
for (let node: TagTree["parent"] = nodes.get(tag.toLowerCase()); node && "parent" in node; node = node.parent) {
"tag" in node && ancestry.push(node.tag);
}
return ancestry;
};

const getTagList = async (options: TagSearchOptions = {noCache: false}) => {
const nodes = (await getTagTrees(options)).values();
const nodes = (await getTagTrees(options)).nodes.values();
const tags = [...nodes].map((node) => node.tag);
return new Set(tags);
};
Expand All @@ -36,5 +36,5 @@ const getTagsFromElement = (element: HTMLTextAreaElement | HTMLInputElement): st
.map((tag) => tag.trim())
.filter((tag) => !!tag) ?? [];

export type {TagTrees};
export type {TagNodes};
export {getAncestry, getTagList, getTagsIncluding, getTagTrees, getTagsFromElement};
33 changes: 20 additions & 13 deletions src/ts/content/adapters/tags/parseTags.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import {TagTree, TagTrees, WarnedTag} from "./types";
import {TagTree, TagNodes, WarnedTag, RawTagSheet, TagRoot} from "./types";

interface ParserOptions {
rows: WarnedTag[][];
fromRow: number;
depth: number;
trees: TagTrees;
parent?: TagTree;
nodes: TagNodes;
parent: TagTree | TagRoot;
}

const parseRows = ({rows, fromRow, depth, trees, parent}: ParserOptions): number => {
const parseRows = ({rows, fromRow, depth, nodes, parent}: ParserOptions): number => {
let row = fromRow;
while (row < rows.length) {
const {tag, warning} = rows[row][depth] ?? {};
if (tag) {
const node = {tag, parent, warning};
// Keys in the tag trees map are lowercase for ez lookup later
trees.set(tag.toLowerCase(), node);
row = parseRows({rows, fromRow: row + 1, depth: depth + 1, trees, parent: node});
const node = {tag, parent, warning, children: []};
parent.children.push(node);
// TODO if nodes already contains this tag, we need to emit a warning somehow!!! see #214
// Keys in the tag nodes map are lowercase for ez lookup later
nodes.set(tag.toLowerCase(), node);
row = parseRows({rows, fromRow: row + 1, depth: depth + 1, nodes, parent: node});
} else {
break;
}
}
return row;
};

const parseTree = (rows: WarnedTag[][]) => {
const trees: TagTrees = new Map();
for (let fromRow = 0; fromRow < rows.length; fromRow = parseRows({rows, fromRow, depth: 0, trees}) + 1);
return trees;
const parseTags = (sheets: RawTagSheet[]) => {
const nodes: TagNodes = new Map();
const roots = sheets.map((sheet) => {
const rows = sheet.values;
const parent = {...sheet, children: []};
for (let fromRow = 0; fromRow < rows.length; fromRow = parseRows({rows, fromRow, depth: 0, nodes, parent}) + 1);
return parent;
});
return {nodes, roots};
};

export {parseTree};
export {parseTags};
19 changes: 15 additions & 4 deletions src/ts/content/adapters/tags/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import {Range} from "../sheets";

type TagMapper = `${string}$TAG${string}`;
type TagSheetDescriptor = {range: Range; mapper: TagMapper; name: string; cwRange?: Range};

type RawTagSheet = TagSheetDescriptor & {values: WarnedTag[][]};

type TagRoot = RawTagSheet & {children: TagTree[]};

/**
* A tag tree contains the properly cased tag at the root,
* and a pointer to its parent in the tree
*/
type TagTree = {tag: string; parent?: TagTree; warning: boolean};
type TagTree = {tag: string; parent: TagTree | TagRoot; warning: boolean; children: TagTree[]};

/**
* TagTrees is a map of lowercase tag to the tag subtree at that tag
* TagNodes is a map of lowercase tag to the tag subtree at that tag
* It contains every subtree of the complete tag tree
*/
type TagTrees = Map<string, TagTree>;
type TagNodes = Map<string, TagTree>;

type Tags = {nodes: TagNodes; roots: TagRoot[]};

interface TagSearchOptions {
noCache: boolean;
}

type WarnedTag = {tag: string; warning: boolean};

export {WarnedTag, TagTree, TagTrees, TagSearchOptions};
export {WarnedTag, TagTree, TagRoot, TagNodes, TagSearchOptions, RawTagSheet, TagSheetDescriptor, TagMapper, Tags};
8 changes: 4 additions & 4 deletions src/ts/content/extensions/util/contentWarningCheck.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {OffSave, OnSave} from "../../entities/bookForm";
import {loaderOverlaid} from "../../../common/ui/loadingIndicator";
import {getTagsFromElement, getTagTrees, TagTrees} from "../../adapters/tags";
import {getTagsFromElement, getTagTrees, TagNodes} from "../../adapters/tags";
import {createModal} from "../../../common/ui/modal";
import {UIColour} from "../../../common/ui/colour";

Expand Down Expand Up @@ -53,7 +53,7 @@ const handleContentWarning = async (
return true;
}
};
const getWarningRequiredTags = (commentsTextArea: HTMLTextAreaElement, tags: string[], trees: TagTrees): string[] => {
const getWarningRequiredTags = (commentsTextArea: HTMLTextAreaElement, tags: string[], trees: TagNodes): string[] => {
if (contentWarningIsPresent(commentsTextArea)) {
return [];
} else {
Expand All @@ -63,9 +63,9 @@ const getWarningRequiredTags = (commentsTextArea: HTMLTextAreaElement, tags: str

const checkTags = async (tagInput: HTMLTextAreaElement, commentsTextArea: HTMLTextAreaElement): Promise<string[]> =>
loaderOverlaid(async () => {
const trees = await getTagTrees();
const {nodes} = await getTagTrees();
const userTags = getTagsFromElement(tagInput);
return getWarningRequiredTags(commentsTextArea, userTags, trees);
return getWarningRequiredTags(commentsTextArea, userTags, nodes);
});

const handleSave = (tagsTextArea: HTMLTextAreaElement, commentsTextArea: HTMLTextAreaElement): Promise<boolean> =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {getTagsFromElement, getTagTrees, TagTrees} from "../../../adapters/tags";
import {getTagsFromElement, getTagTrees, TagNodes} from "../../../adapters/tags";
import {loaderOverlaid} from "../../../../common/ui/loadingIndicator";
import {OffSave, OnSave} from "../../../entities/bookForm";
import {Highlight, Highlightable, highlighted} from "../../../../common/ui/highlighter";
import {getUserAcceptance} from "./dialogs/userAcceptance";
import {GetTagsOptions} from "./types";

const applyHighlights = async (text: string): Promise<Highlight[]> => {
const validTags = await getTagTrees().catch(() => new Map());
const {nodes: validTags} = await getTagTrees().catch(() => ({nodes: new Map()}));
return text
.split(",")
.flatMap((part) => {
Expand All @@ -26,10 +26,10 @@ const applyHighlights = async (text: string): Promise<Highlight[]> => {
.slice(0, -1); // Remove the trailing comma
};

const getInvalidTags = (tags: string[], trees: TagTrees): string[] =>
const getInvalidTags = (tags: string[], trees: TagNodes): string[] =>
tags.filter((tag) => !trees.has(tag.toLowerCase()));

const fixTagsCase = (tags: string[], trees: TagTrees): string[] =>
const fixTagsCase = (tags: string[], trees: TagNodes): string[] =>
tags.map((tag) => trees.get(tag.toLowerCase())?.tag ?? tag);

const setTags = (tagInput: Highlightable, tags: Iterable<string>) => {
Expand All @@ -39,11 +39,11 @@ const setTags = (tagInput: Highlightable, tags: Iterable<string>) => {

const checkTags = async (tagInput: Highlightable, options: GetTagsOptions) =>
loaderOverlaid(async () => {
const trees = await getTagTrees(options);
const {nodes} = await getTagTrees(options);
const userTags = getTagsFromElement(tagInput);
const properCaseTags = fixTagsCase(userTags, trees);
const properCaseTags = fixTagsCase(userTags, nodes);
setTags(tagInput, properCaseTags);
return getInvalidTags(properCaseTags, trees);
return getInvalidTags(properCaseTags, nodes);
});

const handleSave = (tagInput: Highlightable, options: GetTagsOptions) => {
Expand Down

0 comments on commit feccd0b

Please sign in to comment.