Skip to content

Commit

Permalink
Tag Insertion Dialog for unimplemented Tag Insertion (#267)
Browse files Browse the repository at this point in the history
  • Loading branch information
braxtonhall authored Jan 15, 2023
1 parent c68cae4 commit 4c83885
Show file tree
Hide file tree
Showing 17 changed files with 308 additions and 189 deletions.
2 changes: 2 additions & 0 deletions src/sass/modal.sass
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ $modal-background-colour: colours.$white
margin-bottom: 0

.better-library-thing-modal-element-container
overflow-y: auto
max-height: 60vh
width: 100%
margin-top: 20px

Expand Down
1 change: 1 addition & 0 deletions src/ts/common/ui/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,5 @@ const createModal = ({
document.body.appendChild(overlay);
};

export type {ModalButton, ModalInput};
export {createModal, dismissModals};
59 changes: 29 additions & 30 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 @@ -43,52 +40,54 @@ const rowToMappedRange = ([sheet, topLeft, width, cwColumn, userMapper, as]: str
const range: Range = `${sheet}!${topLeft}:${right}`;
const cwRange: Range = cwColumn ? `${sheet}!${cwColumn}${top}:${cwColumn}` : undefined;
const mapper: TagMapper = (userMapper ?? "").includes("$TAG") ? (userMapper as TagMapper) : "$TAG";
return {range, mapper, cwRange, name: as ?? sheet};
return {range, mapper, cwRange, height: Number(width), name: as ?? sheet};
};

const getTagRanges = async (): Promise<MappedRange[]> => {
const range = Sheets.createRange(META_TAG_SHEET, "A", "E");
const getSheetDescriptors = async (): Promise<TagSheetDescriptor[]> => {
const range = Sheets.createRange(META_TAG_SHEET, "A", "F");
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
15 changes: 8 additions & 7 deletions src/ts/content/adapters/tags/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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) {
// TODO maybe we should detect collisions here and then create a modal?
"tag" in node && ancestry.push(node.name);
}
return ancestry;
};

const getTagList = async (options: TagSearchOptions = {noCache: false}) => {
const nodes = (await getTagTrees(options)).values();
const tags = [...nodes].map((node) => node.tag);
const nodes = (await getTagTrees(options)).nodes.values();
const tags = [...nodes].map((node) => node.name);
return new Set(tags);
};

Expand All @@ -36,5 +37,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};
34 changes: 21 additions & 13 deletions src/ts/content/adapters/tags/parseTags.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
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 trimmedTag = tag.trim();
const node = {name: trimmedTag, parent, warning, children: [], height: parent.height - 1};
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(trimmedTag.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 {values: rows, ...rest} = sheet;
const parent: TagRoot = {...rest, 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; height: number};

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

type TagRoot = TagSheetDescriptor & {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 = {name: string; parent: TagTree | TagRoot; warning: boolean; children: TagTree[]; height: number};

/**
* 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};
4 changes: 2 additions & 2 deletions src/ts/content/extensions/author/authorPage/authorUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const appendUI = (container: Element, handlers: ButtonHandlers, getTagsCallback:
viewExistingTags();
};

const insertTags = (tags: string[]) => {
const renderAuthorTags = (tags: string[]) => {
const list = document.getElementById(TAG_LIST_ID);
const input = document.getElementById(AUTHOR_TAG_INPUT_ID) as HTMLInputElement;
if (tags.length === 0) {
Expand All @@ -144,4 +144,4 @@ const getInputElement = () => document.getElementById(AUTHOR_TAG_INPUT_ID) as HT
const viewExistingTags = toggleViews(TAG_LIST_CONTAINER_ID, TAG_INPUT_CONTAINER_ID);
const viewTagEditor = toggleViews(TAG_INPUT_CONTAINER_ID, TAG_LIST_CONTAINER_ID);

export {appendUI, insertTags, getInput, viewExistingTags, viewTagEditor, AUTHOR_TAG_INPUT_ID};
export {appendUI, renderAuthorTags, getInput, viewExistingTags, viewTagEditor, AUTHOR_TAG_INPUT_ID};
4 changes: 2 additions & 2 deletions src/ts/content/extensions/author/authorPage/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Author, {AuthorRecord} from "../../../adapters/author";
import {appendUI, getInput, insertTags, AUTHOR_TAG_INPUT_ID, viewExistingTags, viewTagEditor} from "./authorUI";
import {appendUI, getInput, renderAuthorTags, AUTHOR_TAG_INPUT_ID, viewExistingTags, viewTagEditor} from "./authorUI";
import {loaderOverlaid} from "../../../../common/ui/loadingIndicator";
import Book from "../../../adapters/book";
import {createPushBookTags, createSyncBookTags} from "../util/bookEditor";
Expand All @@ -18,7 +18,7 @@ const onEdit = () => {
const onBackToExistingTags = (getAuthor: () => Promise<AuthorRecord>) => () =>
loaderOverlaid(async () => {
const author = await getAuthor();
author && insertTags(author.tags);
author && renderAuthorTags(author.tags);
viewExistingTags();
});

Expand Down
6 changes: 3 additions & 3 deletions src/ts/content/extensions/author/authorPage/pull.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {createModal} from "../../../../common/ui/modal";
import {getInput, insertTags} from "./authorUI";
import {getInput, renderAuthorTags} from "./authorUI";
import {showToast, ToastType} from "../../../../common/ui/toast";
import {loaderOverlaid} from "../../../../common/ui/loadingIndicator";
import {authorTagsFromBooksWhere, getAuthorInfo} from "./util";
Expand Down Expand Up @@ -30,7 +30,7 @@ const uncertainTagModal =
kind: "button",
text: `Add${strings.all}`,
colour: UIColour.RED,
onClick: async () => insertTags([...certainTags, ...uncertainTags]),
onClick: async () => renderAuthorTags([...certainTags, ...uncertainTags]),
},
{kind: "button", text: "Back", colour: UIColour.BLUE},
],
Expand Down Expand Up @@ -63,7 +63,7 @@ const onPull = async () =>
const multiAuthorTags = authorTagsFromBooksWhere(books, (book) => book.authorIds.length !== 1);
const certainTags = new Set([...getInput(), ...(author?.tags ?? []), ...singleAuthorTags]);
const uncertainTags = new Set(multiAuthorTags.filter((tag) => !certainTags.has(tag)));
insertTags([...certainTags]);
renderAuthorTags([...certainTags]);
return {certainTags, uncertainTags, name};
}).then(finishPull);

Expand Down
4 changes: 2 additions & 2 deletions src/ts/content/extensions/author/authorPage/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Author from "../../../adapters/author";
import {insertTags} from "./authorUI";
import {renderAuthorTags} from "./authorUI";
import {filterAuthorTags} from "../../../util/filterAuthorTags";
import {BookRecord} from "../../../adapters/book";

Expand All @@ -11,7 +11,7 @@ const getAuthorInfo = () => {

const getTags = async () => {
const author = await Author.getAuthor(getAuthorInfo().uuid);
insertTags(author?.tags ?? []);
renderAuthorTags(author?.tags ?? []);
};

const authorTagsFromBooksWhere = (books: BookRecord[], where: (book: BookRecord) => boolean) =>
Expand Down
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
Loading

0 comments on commit 4c83885

Please sign in to comment.