From 4c83885723c9b759dbfcfab9d4600a6b24f13a8f Mon Sep 17 00:00:00 2001 From: Braxton Hall <35436247+braxtonhall@users.noreply.github.com> Date: Sun, 15 Jan 2023 11:04:45 +0100 Subject: [PATCH] Tag Insertion Dialog for unimplemented Tag Insertion (#267) --- src/sass/modal.sass | 2 + src/ts/common/ui/modal.ts | 1 + src/ts/content/adapters/tags/getTags.ts | 59 +++++---- src/ts/content/adapters/tags/index.ts | 15 ++- src/ts/content/adapters/tags/parseTags.ts | 34 +++-- src/ts/content/adapters/tags/types.ts | 19 ++- .../extensions/author/authorPage/authorUI.ts | 4 +- .../extensions/author/authorPage/index.ts | 4 +- .../extensions/author/authorPage/pull.ts | 6 +- .../extensions/author/authorPage/util.ts | 4 +- .../extensions/util/contentWarningCheck.ts | 8 +- .../content/extensions/util/tagValidation.ts | 122 ------------------ .../util/tagValidation/appendTagValidator.ts | 76 +++++++++++ .../util/tagValidation/dialogs/insertTags.ts | 88 +++++++++++++ .../tagValidation/dialogs/userAcceptance.ts | 49 +++++++ .../extensions/util/tagValidation/index.ts | 3 + .../extensions/util/tagValidation/types.ts | 3 + 17 files changed, 308 insertions(+), 189 deletions(-) delete mode 100644 src/ts/content/extensions/util/tagValidation.ts create mode 100644 src/ts/content/extensions/util/tagValidation/appendTagValidator.ts create mode 100644 src/ts/content/extensions/util/tagValidation/dialogs/insertTags.ts create mode 100644 src/ts/content/extensions/util/tagValidation/dialogs/userAcceptance.ts create mode 100644 src/ts/content/extensions/util/tagValidation/index.ts create mode 100644 src/ts/content/extensions/util/tagValidation/types.ts diff --git a/src/sass/modal.sass b/src/sass/modal.sass index 095cea63..6b6e5ffa 100644 --- a/src/sass/modal.sass +++ b/src/sass/modal.sass @@ -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 diff --git a/src/ts/common/ui/modal.ts b/src/ts/common/ui/modal.ts index 69e71488..2323daee 100644 --- a/src/ts/common/ui/modal.ts +++ b/src/ts/common/ui/modal.ts @@ -185,4 +185,5 @@ const createModal = ({ document.body.appendChild(overlay); }; +export type {ModalButton, ModalInput}; export {createModal, dismissModals}; diff --git a/src/ts/content/adapters/tags/getTags.ts b/src/ts/content/adapters/tags/getTags.ts index 004d0e2b..c61c36a5 100644 --- a/src/ts/content/adapters/tags/getTags.ts +++ b/src/ts/content/adapters/tags/getTags.ts @@ -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 @@ -27,12 +24,12 @@ const META_TAG_SHEET = "Tag Index Index"; * The first tag in this table is at A2 */ -const {asyncCached, setCache} = makeCache(); +const {asyncCached, setCache} = makeCache(); 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" @@ -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 => { - const range = Sheets.createRange(META_TAG_SHEET, "A", "E"); +const getSheetDescriptors = async (): Promise => { + 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 => +const extractRanges = (ranges: TagSheetDescriptor[]): Set => ranges.reduce( (acc, {cwRange, range}) => (cwRange ? acc.add(cwRange).add(range) : acc.add(range)), new Set() ); -const nameResponses = (ranges: Range[], response: ValueRange[] | null): Map => { +const nameResponses = (ranges: Range[], response: ValueRange[] | null): Map => { 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) => (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) => + (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 => { - const mappedRanges = await getTagRanges(); +const getSheetsTags = async (): Promise => { + 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 { diff --git a/src/ts/content/adapters/tags/index.ts b/src/ts/content/adapters/tags/index.ts index 582f4e4d..be0df7b8 100644 --- a/src/ts/content/adapters/tags/index.ts +++ b/src/ts/content/adapters/tags/index.ts @@ -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 => { - 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); }; @@ -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}; diff --git a/src/ts/content/adapters/tags/parseTags.ts b/src/ts/content/adapters/tags/parseTags.ts index 47fbff6e..b36f3cb2 100644 --- a/src/ts/content/adapters/tags/parseTags.ts +++ b/src/ts/content/adapters/tags/parseTags.ts @@ -1,22 +1,25 @@ -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; } @@ -24,10 +27,15 @@ const parseRows = ({rows, fromRow, depth, trees, parent}: ParserOptions): number 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}; diff --git a/src/ts/content/adapters/tags/types.ts b/src/ts/content/adapters/tags/types.ts index 8c539a32..94586c1d 100644 --- a/src/ts/content/adapters/tags/types.ts +++ b/src/ts/content/adapters/tags/types.ts @@ -1,14 +1,25 @@ +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; +type TagNodes = Map; + +type Tags = {nodes: TagNodes; roots: TagRoot[]}; interface TagSearchOptions { noCache: boolean; @@ -16,4 +27,4 @@ interface TagSearchOptions { type WarnedTag = {tag: string; warning: boolean}; -export {WarnedTag, TagTree, TagTrees, TagSearchOptions}; +export {WarnedTag, TagTree, TagRoot, TagNodes, TagSearchOptions, RawTagSheet, TagSheetDescriptor, TagMapper, Tags}; diff --git a/src/ts/content/extensions/author/authorPage/authorUI.ts b/src/ts/content/extensions/author/authorPage/authorUI.ts index 842f8d96..3e254a31 100644 --- a/src/ts/content/extensions/author/authorPage/authorUI.ts +++ b/src/ts/content/extensions/author/authorPage/authorUI.ts @@ -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) { @@ -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}; diff --git a/src/ts/content/extensions/author/authorPage/index.ts b/src/ts/content/extensions/author/authorPage/index.ts index 443bf1c4..2479d1b1 100644 --- a/src/ts/content/extensions/author/authorPage/index.ts +++ b/src/ts/content/extensions/author/authorPage/index.ts @@ -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"; @@ -18,7 +18,7 @@ const onEdit = () => { const onBackToExistingTags = (getAuthor: () => Promise) => () => loaderOverlaid(async () => { const author = await getAuthor(); - author && insertTags(author.tags); + author && renderAuthorTags(author.tags); viewExistingTags(); }); diff --git a/src/ts/content/extensions/author/authorPage/pull.ts b/src/ts/content/extensions/author/authorPage/pull.ts index 4d4e5373..e88747fa 100644 --- a/src/ts/content/extensions/author/authorPage/pull.ts +++ b/src/ts/content/extensions/author/authorPage/pull.ts @@ -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"; @@ -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}, ], @@ -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); diff --git a/src/ts/content/extensions/author/authorPage/util.ts b/src/ts/content/extensions/author/authorPage/util.ts index 43fe20b3..1cf96c83 100644 --- a/src/ts/content/extensions/author/authorPage/util.ts +++ b/src/ts/content/extensions/author/authorPage/util.ts @@ -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"; @@ -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) => diff --git a/src/ts/content/extensions/util/contentWarningCheck.ts b/src/ts/content/extensions/util/contentWarningCheck.ts index 68cc3eef..d5932248 100644 --- a/src/ts/content/extensions/util/contentWarningCheck.ts +++ b/src/ts/content/extensions/util/contentWarningCheck.ts @@ -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"; @@ -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 { @@ -63,9 +63,9 @@ const getWarningRequiredTags = (commentsTextArea: HTMLTextAreaElement, tags: str const checkTags = async (tagInput: HTMLTextAreaElement, commentsTextArea: HTMLTextAreaElement): Promise => 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 => diff --git a/src/ts/content/extensions/util/tagValidation.ts b/src/ts/content/extensions/util/tagValidation.ts deleted file mode 100644 index c457f0e1..00000000 --- a/src/ts/content/extensions/util/tagValidation.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {getTagsFromElement, getTagTrees, TagTrees} from "../../adapters/tags"; -import {createModal} from "../../../common/ui/modal"; -import {loaderOverlaid} from "../../../common/ui/loadingIndicator"; -import {UIColour} from "../../../common/ui/colour"; -import {OnSave, OffSave} from "../../entities/bookForm"; -import {Highlight, Highlightable, highlighted} from "../../../common/ui/highlighter"; -import {getSheetLink} from "../../../common/entities/spreadsheet"; - -type GetTagsOptions = {noCache: boolean}; - -const applyHighlights = async (text: string): Promise => { - const validTags = await getTagTrees().catch(() => new Map()); - return text - .split(",") - .flatMap((part) => { - const trimmedPart = part.trim(); - if (!trimmedPart || validTags.has(trimmedPart.toLowerCase())) { - return [part, ","]; - } else { - const highlightedPart: Highlight = {highlight: true, text: trimmedPart}; - if (trimmedPart === part) { - return [highlightedPart, ","]; - } else { - const [before, after] = part.split(trimmedPart); - return [before, highlightedPart, after, ","]; - } - } - }) - .slice(0, -1); // Remove the trailing comma -}; - -const getInvalidTags = (tags: string[], trees: TagTrees): string[] => - tags.filter((tag) => !trees.has(tag.toLowerCase())); - -const getUserAcceptance = ( - invalidTags: string[], - saveHandler: (options: GetTagsOptions) => Promise -): Promise => - new Promise((resolve) => - createModal({ - text: "Are you sure? The following tags are not in the Tag Index", - subText: invalidTags, - elements: [ - { - kind: "button", - text: "Open the Tag Index", - colour: UIColour.GREY, - onClick: async () => resolve(getSecondaryAcceptance(saveHandler)), - }, - {kind: "button", text: "Save anyway", colour: UIColour.GREY, onClick: async () => resolve(true)}, - {kind: "button", text: "Cancel", colour: UIColour.PURPLE, onClick: async () => resolve(false)}, - ], - colour: UIColour.PURPLE, - onCancel: async () => resolve(false), - }) - ); - -const getSecondaryAcceptance = async (saveHandler: (options: GetTagsOptions) => Promise): Promise => { - window.open(await getSheetLink()); - return new Promise((resolve) => - createModal({ - text: "Did you put the new tags in the Tag Index?", - elements: [ - { - kind: "button", - text: "Yes!", - colour: UIColour.GREY, - onClick: async () => resolve(saveHandler({noCache: true})), - }, - {kind: "button", text: "Cancel", colour: UIColour.PURPLE, onClick: async () => resolve(false)}, - ], - colour: UIColour.PURPLE, - onCancel: async () => resolve(false), - }) - ); -}; - -const fixTagsCase = (tags: string[], trees: TagTrees): string[] => - tags.map((tag) => trees.get(tag.toLowerCase())?.tag ?? tag); - -const setTags = (tagInput: Highlightable, tags: Iterable) => { - tagInput.value = [...tags].join(", "); - tagInput.dispatchEvent(new Event("change")); -}; - -const checkTags = async (tagInput: Highlightable, options: GetTagsOptions) => - loaderOverlaid(async () => { - const trees = await getTagTrees(options); - const userTags = getTagsFromElement(tagInput); - const properCaseTags = fixTagsCase(userTags, trees); - setTags(tagInput, properCaseTags); - return getInvalidTags(properCaseTags, trees); - }); - -const handleSave = (tagInput: Highlightable, options: GetTagsOptions) => { - const saveHandler = (options: GetTagsOptions): Promise => - checkTags(tagInput, options).then((invalidTags) => { - if (invalidTags.length > 0) { - return getUserAcceptance(invalidTags, saveHandler); - } else { - return true; - } - }); - return saveHandler(options); -}; - -const appendTagValidator = (onSave: OnSave, offSave: OffSave, input: Highlightable) => { - const backdrop = highlighted(input, applyHighlights); - const saveButtonListener = () => handleSave(input, {noCache: false}); - const showTagValidator = () => { - input.dispatchEvent(new Event("change")); - onSave(saveButtonListener); - backdrop.style.display = ""; - }; - const hideTagValidator = () => { - offSave(saveButtonListener); - backdrop.style.display = "none"; - }; - return {showTagValidator, hideTagValidator}; -}; - -export {appendTagValidator}; diff --git a/src/ts/content/extensions/util/tagValidation/appendTagValidator.ts b/src/ts/content/extensions/util/tagValidation/appendTagValidator.ts new file mode 100644 index 00000000..f7707d04 --- /dev/null +++ b/src/ts/content/extensions/util/tagValidation/appendTagValidator.ts @@ -0,0 +1,76 @@ +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 => { + const {nodes: validTags} = await getTagTrees().catch(() => ({nodes: new Map()})); + return text + .split(",") + .flatMap((part) => { + const trimmedPart = part.trim(); + if (!trimmedPart || validTags.has(trimmedPart.toLowerCase())) { + return [part, ","]; + } else { + const highlightedPart: Highlight = {highlight: true, text: trimmedPart}; + if (trimmedPart === part) { + return [highlightedPart, ","]; + } else { + const [before, after] = part.split(trimmedPart); + return [before, highlightedPart, after, ","]; + } + } + }) + .slice(0, -1); // Remove the trailing comma +}; + +const getInvalidTags = (tags: string[], trees: TagNodes): string[] => + tags.filter((tag) => !trees.has(tag.toLowerCase())); + +const fixTagsCase = (tags: string[], trees: TagNodes): string[] => + tags.map((tag) => trees.get(tag.toLowerCase())?.name ?? tag); + +const setTags = (tagInput: Highlightable, tags: Iterable) => { + tagInput.value = [...tags].join(", "); + tagInput.dispatchEvent(new Event("change")); +}; + +const checkTags = async (tagInput: Highlightable, options: GetTagsOptions) => + loaderOverlaid(async () => { + const {nodes} = await getTagTrees(options); + const userTags = getTagsFromElement(tagInput); + const properCaseTags = fixTagsCase(userTags, nodes); + setTags(tagInput, properCaseTags); + return getInvalidTags(properCaseTags, nodes); + }); + +const handleSave = (tagInput: Highlightable, options: GetTagsOptions) => { + const saveHandler = (options: GetTagsOptions): Promise => + checkTags(tagInput, options).then((invalidTags) => { + if (invalidTags.length > 0) { + return getUserAcceptance(invalidTags, saveHandler); + } else { + return true; + } + }); + return saveHandler(options); +}; + +const appendTagValidator = (onSave: OnSave, offSave: OffSave, input: Highlightable) => { + const backdrop = highlighted(input, applyHighlights); + const saveButtonListener = () => handleSave(input, {noCache: false}); + const showTagValidator = () => { + input.dispatchEvent(new Event("change")); + onSave(saveButtonListener); + backdrop.style.display = ""; + }; + const hideTagValidator = () => { + offSave(saveButtonListener); + backdrop.style.display = "none"; + }; + return {showTagValidator, hideTagValidator}; +}; + +export {appendTagValidator}; diff --git a/src/ts/content/extensions/util/tagValidation/dialogs/insertTags.ts b/src/ts/content/extensions/util/tagValidation/dialogs/insertTags.ts new file mode 100644 index 00000000..de181a54 --- /dev/null +++ b/src/ts/content/extensions/util/tagValidation/dialogs/insertTags.ts @@ -0,0 +1,88 @@ +import {UIColour} from "../../../../../common/ui/colour"; +import {createModal, ModalButton} from "../../../../../common/ui/modal"; +import {getTagTrees} from "../../../../adapters/tags"; +import {loaderOverlaid} from "../../../../../common/ui/loadingIndicator"; +import {TagRoot, TagTree} from "../../../../adapters/tags/types"; + +const tagToInsertionButton = (remainingTags: string[], resolve: (value) => void) => (tag: string) => ({ + kind: "button" as const, + colour: UIColour.BLUE, + text: tag, + onClick: async () => resolve(insertTag(tag, remainingTags).then(insertTags)), +}); + +const insertTags = (tags: string[]): Promise => { + if (tags.length === 0) { + return Promise.resolve(true); + } else { + return new Promise((resolve) => + createModal({ + text: "Which tag would you like to insert?", + elements: [ + ...tags.map(tagToInsertionButton(tags, resolve)), + {kind: "button", colour: UIColour.RED, text: "Back", onClick: async () => resolve(false)}, + ], + colour: UIColour.BLUE, + onCancel: async () => resolve(false), + }) + ); + } +}; + +const nodeToInsertionButton = + (tag: string, remainingTags: string[], resolve: (value) => void) => + (node: TagRoot | TagTree): ModalButton => ({ + kind: "button", + text: node.name, + colour: UIColour.BLUE, + onClick: async () => resolve(insertTagUnder(tag, remainingTags, node)), + }); + +const insertTagUnder = (tag: string, remainingTags: string[], node: TagRoot | TagTree) => { + const hasSubTag = node.height > 1 && node.children.length > 0; + const hasMultipleSubTags = hasSubTag && node.children.length > 1; + const oneOf = hasMultipleSubTags ? "one of " : ""; + const s = hasMultipleSubTags ? "s" : ""; + const subTagMessage = `Or as a sub tag under ${oneOf}the following existing tag${s}?`; + return new Promise((resolve) => + createModal({ + text: `Insert "${tag}" under "${node.name}"?`, + subText: hasSubTag ? [subTagMessage] : [], + elements: [ + { + kind: "button", + text: `Yes! Insert it under "${node.name}"`, + colour: UIColour.GREEN, + onClick: async () => resolve(remainingTags.filter((otherTag) => otherTag !== tag)), + }, + ...(hasSubTag ? node.children.map(nodeToInsertionButton(tag, remainingTags, resolve)) : []), + {kind: "button", text: "Back", colour: UIColour.RED, onClick: async () => resolve(remainingTags)}, + ], + colour: UIColour.BLUE, + onCancel: async () => resolve(remainingTags), + }) + ); +}; + +const insertTag = (tag: string, remainingTags: string[]): Promise => + loaderOverlaid(getTagTrees).then( + ({roots}) => + new Promise((resolve) => + createModal({ + text: "Where would you like to insert this tag?", + elements: [ + ...roots.map(nodeToInsertionButton(tag, remainingTags, resolve)), + { + kind: "button", + text: "Back", + colour: UIColour.RED, + onClick: async () => resolve(remainingTags), + }, + ], + colour: UIColour.BLUE, + onCancel: async () => resolve(remainingTags), + }) + ) + ); + +export {insertTags}; diff --git a/src/ts/content/extensions/util/tagValidation/dialogs/userAcceptance.ts b/src/ts/content/extensions/util/tagValidation/dialogs/userAcceptance.ts new file mode 100644 index 00000000..f1591f6c --- /dev/null +++ b/src/ts/content/extensions/util/tagValidation/dialogs/userAcceptance.ts @@ -0,0 +1,49 @@ +import {createModal} from "../../../../../common/ui/modal"; +import {UIColour} from "../../../../../common/ui/colour"; +import {getSheetLink} from "../../../../../common/entities/spreadsheet"; +import {GetTagsOptions} from "../types"; + +const getUserAcceptance = ( + invalidTags: string[], + saveHandler: (options: GetTagsOptions) => Promise +): Promise => + new Promise((resolve) => + createModal({ + text: "Are you sure? The following tags are not in the Tag Index", + subText: invalidTags, + elements: [ + { + kind: "button", + text: "Open the Tag Index", + colour: UIColour.GREY, + onClick: async () => resolve(getSecondaryAcceptance(saveHandler)), + }, + {kind: "button", text: "Save anyway", colour: UIColour.GREY, onClick: async () => resolve(true)}, + {kind: "button", text: "Cancel", colour: UIColour.PURPLE, onClick: async () => resolve(false)}, + ], + colour: UIColour.PURPLE, + onCancel: async () => resolve(false), + }) + ); + +const getSecondaryAcceptance = async (saveHandler: (options: GetTagsOptions) => Promise): Promise => { + window.open(await getSheetLink()); + return new Promise((resolve) => + createModal({ + text: "Did you put the new tags in the Tag Index?", + elements: [ + { + kind: "button", + text: "Yes!", + colour: UIColour.GREY, + onClick: async () => resolve(saveHandler({noCache: true})), + }, + {kind: "button", text: "Cancel", colour: UIColour.PURPLE, onClick: async () => resolve(false)}, + ], + colour: UIColour.PURPLE, + onCancel: async () => resolve(false), + }) + ); +}; + +export {getUserAcceptance}; diff --git a/src/ts/content/extensions/util/tagValidation/index.ts b/src/ts/content/extensions/util/tagValidation/index.ts new file mode 100644 index 00000000..fd416a65 --- /dev/null +++ b/src/ts/content/extensions/util/tagValidation/index.ts @@ -0,0 +1,3 @@ +import {appendTagValidator} from "./appendTagValidator"; + +export {appendTagValidator}; diff --git a/src/ts/content/extensions/util/tagValidation/types.ts b/src/ts/content/extensions/util/tagValidation/types.ts new file mode 100644 index 00000000..9b67c734 --- /dev/null +++ b/src/ts/content/extensions/util/tagValidation/types.ts @@ -0,0 +1,3 @@ +type GetTagsOptions = {noCache: boolean}; + +export type {GetTagsOptions};