Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tag Insertion Dialog #267

Merged
merged 16 commits into from
Jan 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how often does this code get run? and what would the user do with this information if we show them the modal?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe the modal should happen at a later step.
For example, when adding ancestor tags.

Or maybe? Instead of a tree its a network, and we just go for it and add all ancestors on multiple paths?

"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