From 061619d5b949f497212372d4bf0d911da8008b65 Mon Sep 17 00:00:00 2001 From: Christopher Loverich <1010084+cloverich@users.noreply.github.com> Date: Sat, 15 Jun 2024 08:08:53 -0700 Subject: [PATCH] add document tagging adds a basic interface for tagging documents, searching by tags, and quick-links for available tags / search to the sidebar --- scripts/dev.js | 2 + src/electron/migrations/20211005142122.sql | 16 +- src/hooks/useTags.ts | 39 ++++ src/markdown/index.ts | 14 ++ .../plugins/remark-to-slate.ts | 2 +- .../plugins/slate-to-remark.ts | 7 +- src/preload/client/documents.ts | 164 ++++++++++------ src/preload/client/index.ts | 2 + src/preload/client/tags.ts | 18 ++ src/preload/client/types.ts | 2 + src/preload/importer/importChronicles.ts | 1 + src/views/documents/Layout.tsx | 99 +--------- src/views/documents/SearchParser.ts | 12 +- src/views/documents/SearchStore.ts | 7 +- src/views/documents/Sidebar.tsx | 127 +++++++++++++ .../documents/search/SearchParser.test.ts | 23 +++ src/views/documents/search/index.tsx | 7 +- src/views/documents/search/parsers/filter.ts | 1 - src/views/documents/search/parsers/focus.ts | 178 +++++++++--------- src/views/documents/search/parsers/tag.ts | 53 ++++++ src/views/documents/search/tokens.ts | 8 +- src/views/edit/EditableDocument.ts | 19 +- src/views/edit/index.tsx | 50 +++++ src/views/edit/useEditableDocument.ts | 2 +- 24 files changed, 589 insertions(+), 264 deletions(-) create mode 100644 src/hooks/useTags.ts create mode 100644 src/preload/client/tags.ts create mode 100644 src/views/documents/Sidebar.tsx create mode 100644 src/views/documents/search/parsers/tag.ts diff --git a/scripts/dev.js b/scripts/dev.js index 999cb92..8b03428 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -96,6 +96,7 @@ async function watchRenderer() { bundle: true, platform: "browser", plugins: [startElectronPlugin("renderer")], + sourcemap: true, }); await ctxRenderer.watch(); @@ -109,6 +110,7 @@ async function watchPreload() { platform: "node", external: ["knex", "electron", "electron-store", "better-sqlite3"], plugins: [startElectronPlugin("preload")], + sourcemap: true, }); await ctxPreload.watch(); diff --git a/src/electron/migrations/20211005142122.sql b/src/electron/migrations/20211005142122.sql index 1adbd0e..6c2ffea 100644 --- a/src/electron/migrations/20211005142122.sql +++ b/src/electron/migrations/20211005142122.sql @@ -30,11 +30,17 @@ CREATE TABLE IF NOT EXISTS "documents" ( FOREIGN KEY ("journalId") REFERENCES "journals" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); --- CreateIndex -CREATE UNIQUE INDEX IF NOT EXISTS "journals_name_uniq" ON "journals"("name"); --- CreateIndex -CREATE INDEX IF NOT EXISTS "documents_title_idx" ON "documents"("title"); +-- CreateTable +CREATE TABLE IF NOT EXISTS "document_tags" ( + "documentId" TEXT NOT NULL, + "tag" TEXT NOT NULL, + FOREIGN KEY ("documentId") REFERENCES "documents" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY ("documentId", "tag") +); --- CreateIndex + +CREATE UNIQUE INDEX IF NOT EXISTS "journals_name_uniq" ON "journals"("name"); +CREATE INDEX IF NOT EXISTS "documents_title_idx" ON "documents"("title"); CREATE INDEX IF NOT EXISTS "documents_createdat_idx" ON "documents"("createdAt"); +CREATE INDEX IF NOT EXISTS "tags_name_idx" ON "document_tags"("tag"); \ No newline at end of file diff --git a/src/hooks/useTags.ts b/src/hooks/useTags.ts new file mode 100644 index 0000000..2afc1ae --- /dev/null +++ b/src/hooks/useTags.ts @@ -0,0 +1,39 @@ +import React from "react"; +import useClient from "./useClient"; + +/** + * Hook for loading tags. + */ +export function useTags() { + const [loading, setLoading] = React.useState(true); + const [tags, setTags] = React.useState([]); + const [error, setError] = React.useState(null); + const client = useClient(); + + React.useEffect(() => { + let isEffectMounted = true; + setLoading(true); + + async function load() { + try { + const tags = await client.tags.all(); + if (!isEffectMounted) return; + + setTags(tags); + setLoading(false); + } catch (err: any) { + if (!isEffectMounted) return; + + setError(err); + setLoading(false); + } + } + + load(); + return () => { + isEffectMounted = false; + }; + }, []); + + return { loading, tags, error }; +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index d70166d..96d6d67 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -8,6 +8,20 @@ import { Node as SNode } from "slate"; export * from "ts-mdast"; +// I usually forget how unified works, so just leaving some notes for reference +// https://github.com/orgs/unifiedjs/discussions/113 +// | ........................ process ........................... | +// | .......... parse ... | ... run ... | ... stringify ..........| +// +// +--------+ +----------+ +// Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output +// +--------+ | +----------+ +// X +// | +// +--------------+ +// | Transformers | +// +--------------+ + const stringifier = unified().use(remarkStringify); const parser = unified().use(remarkParse).use(remarkGfm); diff --git a/src/markdown/remark-slate-transformer/plugins/remark-to-slate.ts b/src/markdown/remark-slate-transformer/plugins/remark-to-slate.ts index de9a79b..b81bf73 100644 --- a/src/markdown/remark-slate-transformer/plugins/remark-to-slate.ts +++ b/src/markdown/remark-slate-transformer/plugins/remark-to-slate.ts @@ -1,6 +1,6 @@ import { mdastToSlate } from "../transformers/mdast-to-slate"; -export default function plugin() { +export default function remarkToSlatePlugin() { // @ts-ignore this.Compiler = function (node: any) { return mdastToSlate(node); diff --git a/src/markdown/remark-slate-transformer/plugins/slate-to-remark.ts b/src/markdown/remark-slate-transformer/plugins/slate-to-remark.ts index ffd4c1f..fa254ae 100644 --- a/src/markdown/remark-slate-transformer/plugins/slate-to-remark.ts +++ b/src/markdown/remark-slate-transformer/plugins/slate-to-remark.ts @@ -3,10 +3,13 @@ import { slateToMdast } from "../transformers/slate-to-mdast"; type Settings = {}; -const plugin: Plugin<[Settings?]> = function (settings?: Settings) { +const slateToRemarkPlugin: Plugin<[Settings?]> = function ( + settings?: Settings, +) { // @ts-ignore return function (node: any) { return slateToMdast(node); }; }; -export default plugin; + +export default slateToRemarkPlugin; diff --git a/src/preload/client/documents.ts b/src/preload/client/documents.ts index fe76f91..5723a54 100644 --- a/src/preload/client/documents.ts +++ b/src/preload/client/documents.ts @@ -9,6 +9,7 @@ export interface GetDocumentResponse { title?: string; content: string; journalId: string; + tags: string[]; } /** @@ -36,6 +37,11 @@ export interface SearchRequest { */ texts?: string[]; + /** + * Search document #tags. ex: ['mytag', 'othertag'] + */ + tags?: string[]; + limit?: number; nodeMatch?: { @@ -64,7 +70,6 @@ export interface SearchItem { journalId: string; } -// Now straight up copying from the API layer export interface SaveRawRequest { journalName: string; date: string; @@ -84,6 +89,7 @@ export interface SaveRequest { journalId: string; content: string; title?: string; + tags: string[]; // these included for override, originally, // to support the import process @@ -99,16 +105,18 @@ export class DocumentsClient { private knex: Knex, ) {} - findById = ({ - documentId, - }: { - documentId: string; - }): Promise => { - const doc = this.db - .prepare("select * from documents where id = :id") - .get({ id: documentId }); - - return doc; + findById = ({ id }: { id: string }): Promise => { + const document = this.db + .prepare(`SELECT * FROM documents WHERE id = :id`) + .get({ id }); + const documentTags = this.db + .prepare(`SELECT tag FROM document_tags WHERE documentId = :documentId`) + .all({ documentId: id }) + .map((row) => row.tag); + return { + ...document, + tags: documentTags, + }; }; del = async (id: string) => { @@ -116,8 +124,6 @@ export class DocumentsClient { }; search = async (q?: SearchRequest): Promise => { - // todo: consider using raw and getting arrays of values rather than - // objects for each row, for performance let query = this.knex("documents"); // filter by journal @@ -125,6 +131,12 @@ export class DocumentsClient { query = query.whereIn("journalId", q.journals); } + if (q?.tags?.length) { + query = query + .join("document_tags", "documents.id", "document_tags.documentId") + .whereIn("document_tags.tag", q.tags); + } + // filter by title if (q?.titles?.length) { for (const title of q.titles) { @@ -166,65 +178,95 @@ export class DocumentsClient { return { data: [] }; }; - save = ({ - id, + /** + * Create or update a document and its tags + * + * todo: test; for tags: test prefix is removed, spaces are _, lowercased, max length + * todo: test description max length + * + * @returns - The document as it exists after the save + */ + save = (args: SaveRequest): Promise => { + // de-dupe tags -- should happen before getting here. + args.tags = Array.from(new Set(args.tags)); + + const id = this.db.transaction(() => { + if (args.id) { + this.updateDocument(args); + return args.id; + } else { + return this.createDocument(args); + } + })(); + + return this.findById({ id }); + }; + + private createDocument = ({ createdAt, updatedAt, journalId, content, title, - }: SaveRequest): Promise => { - if (id) { + tags, + }: SaveRequest): string => { + const id = uuidv7(); + this.db + .prepare( + `INSERT INTO documents (id, journalId, content, title, createdAt, updatedAt) VALUES (:id, :journalId, :content, :title, :createdAt, :updatedAt)`, + ) + .run({ + id, + journalId, + content, + title, + // allow passing createdAt to support backfilling prior notes + createdAt: createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + if (tags.length > 0) { this.db .prepare( - ` - update documents set - journalId=:journalId, - content=:content, - title=:title, - updatedAt=:updatedAt, - createdAt=:createdAt - where - id=:id - `, - ) - .run({ - id, - content, - title, - journalId, - updatedAt: new Date().toISOString(), - createdAt, - }); - - return this.db - .prepare( - `select * from documents where id = :id order by "createdAt" desc`, + `INSERT INTO document_tags (documentId, tag) VALUES ${tags.map((tag) => `(:documentId, '${tag}')`).join(", ")}`, ) - .get({ id }); - } else { - const id = uuidv7(); + .run({ documentId: id }); + } + + return id; + }; + + private updateDocument = ({ + id, + createdAt, + journalId, + content, + title, + tags, + }: SaveRequest): void => { + this.db + .prepare( + `UPDATE documents SET journalId=:journalId, content=:content, title=:title, updatedAt=:updatedAt, createdAt=:createdAt WHERE id=:id`, + ) + .run({ + id, + content, + title, + journalId, + updatedAt: new Date().toISOString(), + createdAt, + }); + + this.db + .prepare(`DELETE FROM document_tags WHERE documentId = :documentId`) + .run({ documentId: id }); + + if (tags.length > 0) { this.db .prepare( - ` - insert into documents (id, journalId, content, title, createdAt, updatedAt) - values (:id, :journalId, :content, :title, :createdAt, :updatedAt) - `, - ) - .run({ - id, - journalId, - content, - title, - createdAt: createdAt || new Date().toISOString(), - updatedAt: updatedAt || new Date().toISOString(), - }); - - return this.db - .prepare( - `select * from documents where id = :id order by "createdAt" desc`, + `INSERT INTO document_tags (documentId, tag) VALUES ${tags.map((tag) => `(:documentId, '${tag}')`).join(", ")}`, ) - .get({ id }); + .run({ documentId: id }); } }; diff --git a/src/preload/client/index.ts b/src/preload/client/index.ts index 5b1faed..b5ab1a0 100644 --- a/src/preload/client/index.ts +++ b/src/preload/client/index.ts @@ -1,5 +1,6 @@ import { JournalsClient } from "./journals"; import { DocumentsClient } from "./documents"; +import { TagsClient } from "./tags"; import { PreferencesClient } from "./preferences"; import { FilesClient } from "./files"; import { IClient } from "./types"; @@ -33,6 +34,7 @@ export function create(): IClient { client = { journals: new JournalsClient(db), documents: new DocumentsClient(db, knex), + tags: new TagsClient(db, knex), preferences: new PreferencesClient(settings), files: new FilesClient(settings), }; diff --git a/src/preload/client/tags.ts b/src/preload/client/tags.ts new file mode 100644 index 0000000..6bc4ec4 --- /dev/null +++ b/src/preload/client/tags.ts @@ -0,0 +1,18 @@ +import { Database } from "better-sqlite3"; +import { Knex } from "knex"; + +export type ITagsClient = TagsClient; + +export class TagsClient { + constructor( + private db: Database, + private knex: Knex, + ) {} + + all = async (): Promise => { + return this.db + .prepare(`SELECT DISTINCT tag FROM document_tags`) + .all() + .map((row) => row.tag); + }; +} diff --git a/src/preload/client/types.ts b/src/preload/client/types.ts index b5070ba..391bf36 100644 --- a/src/preload/client/types.ts +++ b/src/preload/client/types.ts @@ -2,6 +2,7 @@ import { IJournalsClient } from "./journals"; import { IDocumentsClient } from "./documents"; import { IPreferencesClient } from "./preferences"; import { IFilesClient } from "./files"; +import { ITagsClient } from "./tags"; // This interface was created with these "I" types like this // so non-preload code could import this type without the bundler @@ -9,6 +10,7 @@ import { IFilesClient } from "./files"; // to be run in a node environment. export interface IClient { journals: IJournalsClient; + tags: ITagsClient; documents: IDocumentsClient; preferences: IPreferencesClient; files: IFilesClient; diff --git a/src/preload/importer/importChronicles.ts b/src/preload/importer/importChronicles.ts index 2b4216b..b7e0ec1 100644 --- a/src/preload/importer/importChronicles.ts +++ b/src/preload/importer/importChronicles.ts @@ -69,6 +69,7 @@ export async function importChronicles(notesDir: string) { updatedAt: date.toISO()!, content: document.content, title: document.title, + tags: [], // todo }); console.log("created", doc.id); } diff --git a/src/views/documents/Layout.tsx b/src/views/documents/Layout.tsx index b1cb758..a981b99 100644 --- a/src/views/documents/Layout.tsx +++ b/src/views/documents/Layout.tsx @@ -1,20 +1,9 @@ -import React, { useContext } from "react"; -import { - Pane, - SideSheet, - Card, - Heading, - UnorderedList, - ListItem, - FolderCloseIcon, - IconButton, - FolderOpenIcon, -} from "evergreen-ui"; -import TagSearch from "./search"; +import React from "react"; +import { Pane, IconButton, FolderOpenIcon } from "evergreen-ui"; +import SearchDocuments from "./search"; import { Link } from "react-router-dom"; import { SearchStore } from "./SearchStore"; -import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; -import { JournalResponse } from "../../hooks/useClient"; +import { JournalSelectionSidebar } from "./Sidebar"; interface Props { store: SearchStore; @@ -40,7 +29,7 @@ export function Layout(props: Props) { setIsShown={setIsSidebarOpen} search={props.store} /> - + Create new @@ -50,84 +39,8 @@ export function Layout(props: Props) { ); } -interface SidebarProps { +export interface SidebarProps { isShown: boolean; setIsShown: (isShown: boolean) => void; search: SearchStore; } - -/** - * Sidebar for selecting journals to search. - */ -function JournalSelectionSidebar(props: SidebarProps) { - const { isShown, setIsShown } = props; - const jstore = useContext(JournalsStoreContext); - const searchStore = props.search; - - function search(journal: string) { - searchStore.setSearch([`in:${journal}`]); - setIsShown(false); - return false; - } - - return ( - - setIsShown(false)} - preventBodyScrolling - containerProps={{ - display: "flex", - flex: "1", - flexDirection: "column", - }} - > - - - Journals - - - - - - - - - ); -} - -function JournalsCard(props: { - journals: JournalResponse[]; - title: string; - search: (journalName: string) => boolean; -}) { - if (!props.journals.length) { - return null; - } - - const journals = props.journals.map((j) => { - return ( - - props.search(j.name)}> - {j.name} - - - ); - }); - - return ( - - {props.title} - {journals} - - ); -} diff --git a/src/views/documents/SearchParser.ts b/src/views/documents/SearchParser.ts index 53f765a..954a2c4 100644 --- a/src/views/documents/SearchParser.ts +++ b/src/views/documents/SearchParser.ts @@ -1,10 +1,11 @@ import { SearchToken } from "./search/tokens"; -import { FocusTokenParser } from "./search/parsers/focus"; +// import { FocusTokenParser } from "./search/parsers/focus"; import { JournalTokenParser } from "./search/parsers/in"; import { FilterTokenParser } from "./search/parsers/filter"; import { TitleTokenParser } from "./search/parsers/title"; import { TextTokenParser } from "./search/parsers/text"; import { BeforeTokenParser } from "./search/parsers/before"; +import { TagTokenParser } from "./search/parsers/tag"; // TODO: This won't allow searching where value has colon in it const tokenRegex = /^(.*):(.*)/; @@ -20,9 +21,10 @@ interface TokenParser { // todo: Type this as , where the type key matches // a specific parser that corresponds to it. const parsers: Record> = { - focus: new FocusTokenParser(), + // focus: new FocusTokenParser(), in: new JournalTokenParser(), filter: new FilterTokenParser(), + tag: new TagTokenParser(), title: new TitleTokenParser(), text: new TextTokenParser(), before: new BeforeTokenParser(), @@ -43,7 +45,7 @@ export class SearchParser { * * @param tokenStr - The raw string from the search input */ - private parserFor( + private parseFor( tokenStr: string, ): [TokenParser, SearchToken] | undefined { if (!tokenStr) return; @@ -68,7 +70,7 @@ export class SearchParser { } parseToken = (tokenStr: string) => { - const results = this.parserFor(tokenStr); + const results = this.parseFor(tokenStr); if (!results) return; const [_, parsedToken] = results; @@ -94,7 +96,7 @@ export class SearchParser { }; removeToken = (tokens: any[], tokenStr: string) => { - const results = this.parserFor(tokenStr); + const results = this.parseFor(tokenStr); if (!results) return tokens; const [parser, parsedToken] = results; diff --git a/src/views/documents/SearchStore.ts b/src/views/documents/SearchStore.ts index c53e966..23c9db6 100644 --- a/src/views/documents/SearchStore.ts +++ b/src/views/documents/SearchStore.ts @@ -16,6 +16,7 @@ interface SearchQuery { journals: string[]; titles?: string[]; before?: string; + tags?: string[]; texts?: string[]; limit?: number; } @@ -88,6 +89,10 @@ export class SearchStore { .map((token) => this.journals.idForName(token.value as string)) .filter((token) => token) as string[]; + const tags = this.tokens + .filter((t) => t.type === "tag") + .map((t) => t.value) as string[]; + // todo: Typescript doesn't know when I filter to type === 'title' its TitleTokens // its confused by the nodeMatch type const titles = this.tokens @@ -103,7 +108,7 @@ export class SearchStore { before = beforeToken.value as string; } - return { journals, titles, texts, before }; + return { journals, tags, titles, texts, before }; }; /** diff --git a/src/views/documents/Sidebar.tsx b/src/views/documents/Sidebar.tsx new file mode 100644 index 0000000..d9aeeab --- /dev/null +++ b/src/views/documents/Sidebar.tsx @@ -0,0 +1,127 @@ +import React, { useContext } from "react"; +import { + Pane, + SideSheet, + Card, + Heading, + UnorderedList, + ListItem, + FolderCloseIcon, +} from "evergreen-ui"; +import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; +import { JournalResponse } from "../../hooks/useClient"; +import { useTags } from "../../hooks/useTags"; +import { SidebarProps } from "./Layout"; + +/** + * Sidebar for selecting journals or tags to search by. + */ +export function JournalSelectionSidebar(props: SidebarProps) { + const { isShown, setIsShown } = props; + const jstore = useContext(JournalsStoreContext); + const searchStore = props.search; + + function search(journal: string) { + searchStore.setSearch([`in:${journal}`]); + setIsShown(false); + return false; + } + + function searchTag(tag: string) { + searchStore.setSearch([`tag:${tag}`]); + setIsShown(false); + return false; + } + + return ( + + setIsShown(false)} + preventBodyScrolling + containerProps={{ + display: "flex", + flex: "1", + flexDirection: "column", + }} + > + + + Journals + + + + + + + + + + ); +} + +function TagsCard(props: { search: (tag: string) => boolean }) { + const { loading, error, tags } = useTags(); + + if (loading) { + return "loading..."; + } + + if (error) { + console.error("error loading tags", error); + return "error loading tags"; + } + + const tagItems = tags.map((t) => { + return ( + + props.search(t)}> + {t} + + + ); + }); + + return ( + + Tags + {tagItems} + + ); +} + +function JournalsCard(props: { + journals: JournalResponse[]; + title: string; + search: (journalName: string) => boolean; +}) { + if (!props.journals.length) { + return null; + } + + const journals = props.journals.map((j) => { + return ( + + props.search(j.name)}> + {j.name} + + + ); + }); + + return ( + + {props.title} + {journals} + + ); +} diff --git a/src/views/documents/search/SearchParser.test.ts b/src/views/documents/search/SearchParser.test.ts index 480eb78..3494fe6 100644 --- a/src/views/documents/search/SearchParser.test.ts +++ b/src/views/documents/search/SearchParser.test.ts @@ -106,6 +106,29 @@ suite("SearchParser", function () { }); }); + test("tag:", function () { + const pasrer = new SearchParser(); + const tagToken = pasrer.parseToken("tag:javascript"); + + assert.exists(tagToken); + assert.deepEqual(tagToken, { + type: "tag", + value: "javascript", + }); + }); + + test("tag: with #", function () { + const pasrer = new SearchParser(); + const tagToken = pasrer.parseToken("tag:#javascript"); + + assert.exists(tagToken); + assert.deepEqual(tagToken, { + type: "tag", + // should remove the leading # + value: "javascript", + }); + }); + test("title:", function () { const pasrer = new SearchParser(); const titleToken = pasrer.parseToken("title:fix testing, sigh esm?"); diff --git a/src/views/documents/search/index.tsx b/src/views/documents/search/index.tsx index 4a2cf0c..392761a 100644 --- a/src/views/documents/search/index.tsx +++ b/src/views/documents/search/index.tsx @@ -7,7 +7,10 @@ interface Props { store: SearchStore; } -const TagSearch = (props: Props) => { +/** + * SearchDocuments is a component that provides a search input for searching documents. + */ +const SearchDocuments = (props: Props) => { function onRemove(tag: string | React.ReactNode, idx: number) { if (typeof tag !== "string") return; props.store.removeToken(tag); @@ -41,4 +44,4 @@ const TagSearch = (props: Props) => { ); }; -export default observer(TagSearch); +export default observer(SearchDocuments); diff --git a/src/views/documents/search/parsers/filter.ts b/src/views/documents/search/parsers/filter.ts index 890699d..fb034a7 100644 --- a/src/views/documents/search/parsers/filter.ts +++ b/src/views/documents/search/parsers/filter.ts @@ -86,7 +86,6 @@ function parseFilter(token: string): NodeMatch | undefined { } else { const nodeMatch = token.match(nodeRegex); if (nodeMatch && nodeTypes.includes(nodeMatch[1])) { - console.log("nodeMatch", nodeMatch[1]); return { type: nodeMatch[1], text: undefined, diff --git a/src/views/documents/search/parsers/focus.ts b/src/views/documents/search/parsers/focus.ts index 087b910..a44e1cd 100644 --- a/src/views/documents/search/parsers/focus.ts +++ b/src/views/documents/search/parsers/focus.ts @@ -1,102 +1,102 @@ -import { FocusToken, SearchToken } from "../tokens"; +// import { FocusToken, SearchToken } from "../tokens"; -export class FocusTokenParser { - prefix = "focus:"; - serialize = (token: FocusToken) => { - return `focus:${token.value.content}`; - }; +// export class FocusTokenParser { +// prefix = "focus:"; +// serialize = (token: FocusToken) => { +// return `focus:${token.value.content}`; +// }; - parse = (text: string): FocusToken | undefined => { - if (!text) return undefined; - return { type: "focus", value: parseHeading(text) }; - }; +// parse = (text: string): FocusToken | undefined => { +// if (!text) return undefined; +// return { type: "focus", value: parseHeading(text) }; +// }; - add = (tokens: SearchToken[], token: FocusToken) => { - // there can be only one... - // This isn't the right place to put this business logic... - const filtered = tokens.filter((t) => t.type !== "focus"); - filtered.push(token); +// add = (tokens: SearchToken[], token: FocusToken) => { +// // there can be only one... +// // This isn't the right place to put this business logic... +// const filtered = tokens.filter((t) => t.type !== "focus"); +// filtered.push(token); - return filtered; - }; +// return filtered; +// }; - remove = (tokens: SearchToken[], token: FocusToken) => { - // Find the token matching this one... and remove it... - return tokens.filter((t) => { - // Keep all non-focus tokens - if (t.type !== "focus") return true; +// remove = (tokens: SearchToken[], token: FocusToken) => { +// // Find the token matching this one... and remove it... +// return tokens.filter((t) => { +// // Keep all non-focus tokens +// if (t.type !== "focus") return true; - // Remove if it matches... - return ( - t.value.content !== token.value.content || - t.value.depth !== token.value.depth - ); - }); - }; -} +// // Remove if it matches... +// return ( +// t.value.content !== token.value.content || +// t.value.depth !== token.value.depth +// ); +// }); +// }; +// } -/** - * # foo -- Yes - * ## foo -- Yes - * #foo -- No - * foo #bar # baz -- No - */ -const hRegex = /^(#+) (.*)/; +// /** +// * # foo -- Yes +// * ## foo -- Yes +// * #foo -- No +// * foo #bar # baz -- No +// */ +// const hRegex = /^(#+) (.*)/; -/** - * Get the text part of a heading search. - * - * Could be combined with extractHeading because of how I use it, but - * good design is unclear to me atm. - * - * @param text -- search token for heading - */ -function parseHeadingText(text: string): string { - const matches = text.match(hRegex); - if (!matches) return text; - return matches[2]; -} +// /** +// * Get the text part of a heading search. +// * +// * Could be combined with extractHeading because of how I use it, but +// * good design is unclear to me atm. +// * +// * @param text -- search token for heading +// */ +// function parseHeadingText(text: string): string { +// const matches = text.match(hRegex); +// if (!matches) return text; +// return matches[2]; +// } -type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; +// type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -function tagForDepth(text: string): HeadingTag { - if (!text.length || text.length > 6) { - console.warn( - "tagForDepth expected between 1-6 hashes, but got ", - text.length, - text, - "returning `h1` as a default", - ); - return "h1"; - } +// function tagForDepth(text: string): HeadingTag { +// if (!text.length || text.length > 6) { +// console.warn( +// "tagForDepth expected between 1-6 hashes, but got ", +// text.length, +// text, +// "returning `h1` as a default", +// ); +// return "h1"; +// } - return `h${text.length}` as HeadingTag; -} +// return `h${text.length}` as HeadingTag; +// } -/** - * Parse a token as a focused heading command, convert - * the text part to a node search query (i.e. searchStore.query.node) - * - * todo: Refactor searchStore.query.node to be more generic, and - * to have a first class name (like "node search") - * @param text - */ -function parseHeading(text: string) { - // ['##', 'search text'] - const matches = text.match(hRegex); +// /** +// * Parse a token as a focused heading command, convert +// * the text part to a node search query (i.e. searchStore.query.node) +// * +// * todo: Refactor searchStore.query.node to be more generic, and +// * to have a first class name (like "node search") +// * @param text +// */ +// function parseHeading(text: string) { +// // ['##', 'search text'] +// const matches = text.match(hRegex); - if (!matches) { - // Infer h1, preprend '# ' to search - return { - type: "heading", - content: "# " + text, - depth: "h1" as HeadingTag, - }; - } else { - return { - type: "heading", - content: text, //matches[2], - depth: tagForDepth(matches[1]), - }; - } -} +// if (!matches) { +// // Infer h1, preprend '# ' to search +// return { +// type: "heading", +// content: "# " + text, +// depth: "h1" as HeadingTag, +// }; +// } else { +// return { +// type: "heading", +// content: text, //matches[2], +// depth: tagForDepth(matches[1]), +// }; +// } +// } diff --git a/src/views/documents/search/parsers/tag.ts b/src/views/documents/search/parsers/tag.ts new file mode 100644 index 0000000..8db7eaa --- /dev/null +++ b/src/views/documents/search/parsers/tag.ts @@ -0,0 +1,53 @@ +import { SearchToken, TagToken } from "../tokens"; + +export class TagTokenParser { + prefix = "tag:"; + + serialize = (token: TagToken) => { + return this.prefix + token.value; + }; + + parse = (text: string): TagToken | undefined => { + if (!text) return; + + // trim # from the beginning of the tag + if (text.startsWith("#")) { + text = text.slice(1); + } + + // remove spaces, snake_case + // changing value here changes it in the index view, but not ht eadd tags view + text = text.replace(/ /g, "_"); + text = text.toLowerCase(); + + // max length, probably all search tokens need this? Or only this one since its persisted? + // todo: A consistent strategy here. + text = text.slice(0, 15); + + return { type: "tag", value: text }; + }; + + add = (tokens: SearchToken[], token: TagToken) => { + // there can be only one of each named journal + if (tokens.find((t) => t.type === "tag" && t.value === token.value)) { + return tokens; + } + + // returning a copy is consistent with other methods, + // but feels useless + const copy = tokens.slice(); + copy.push(token); + return copy; + }; + + remove = (tokens: SearchToken[], token: TagToken) => { + // Find the token matching this one... and remove it... + return tokens.filter((t) => { + // Keep all non-journal tokens + if (t.type !== "tag") return true; + + // Remove if it matches... + return t.value !== token.value; + }); + }; +} diff --git a/src/views/documents/search/tokens.ts b/src/views/documents/search/tokens.ts index 082b42f..df7e87e 100644 --- a/src/views/documents/search/tokens.ts +++ b/src/views/documents/search/tokens.ts @@ -44,6 +44,11 @@ export type FocusToken = { }; }; +export type TagToken = { + type: "tag"; + value: string; +}; + /** * Searching documents by title */ @@ -71,7 +76,8 @@ export type BeforeToken = { export type SearchToken = | FilterToken | JournalToken - | FocusToken + // | FocusToken + | TagToken | TitleToken | TextToken | BeforeToken; diff --git a/src/views/edit/EditableDocument.ts b/src/views/edit/EditableDocument.ts index 3fba0d0..eb0bbd0 100644 --- a/src/views/edit/EditableDocument.ts +++ b/src/views/edit/EditableDocument.ts @@ -58,6 +58,7 @@ export class EditableDocument { @observable id?: string; @observable createdAt: string; @observable updatedAt: string; // read-only outside this class + @observable tags: string[] = []; // editor properties slateContent: SlateNode[]; @@ -78,6 +79,7 @@ export class EditableDocument { this.id = doc.id; this.createdAt = doc.createdAt; this.updatedAt = doc.updatedAt; + this.tags = doc.tags; const content = doc.content; const slateNodes = SlateTransformer.nodify(content); this.slateContent = slateNodes; @@ -85,6 +87,7 @@ export class EditableDocument { this.createdAt = new Date().toISOString(); this.updatedAt = new Date().toISOString(); this.slateContent = SlateTransformer.createEmptyNodes(); + this.tags = []; } // Auto-save @@ -99,6 +102,7 @@ export class EditableDocument { changeCount: this.changeCount, title: this.title, journal: this.journalId, + tags: this.tags.slice(), // must access elements to watch them }; }, () => { @@ -131,12 +135,21 @@ export class EditableDocument { this.dirty = false; this.content = SlateTransformer.stringify(toJS(this.slateContent)); + let wasError = false; try { // note: I was passing documentId instead of id, and because id is optional in save it wasn't complaining. // Maybe 'save' and optional, unvalidated params is a bad idea :| const res = await this.client.documents.save( - pick(toJS(this), "title", "content", "journalId", "id", "createdAt"), + pick( + toJS(this), + "title", + "content", + "journalId", + "id", + "createdAt", + "tags", + ), ); this.id = res.id; this.createdAt = res.createdAt; @@ -144,12 +157,14 @@ export class EditableDocument { } catch (err) { this.saving = false; this.dirty = true; + wasError = true; toaster.danger(JSON.stringify(err)); } finally { this.saving = false; // if edits made after last save attempt, re-run - if (this.dirty) this.save(); + // Check error to avoid infinite save loop + if (this.dirty && !wasError) this.save(); } }, 1000); diff --git a/src/views/edit/index.tsx b/src/views/edit/index.tsx index 7ef4462..57066bb 100644 --- a/src/views/edit/index.tsx +++ b/src/views/edit/index.tsx @@ -9,6 +9,7 @@ import { Position, Tab, Tablist, + TagInput, } from "evergreen-ui"; import { useEditableDocument } from "./useEditableDocument"; import { EditableDocument } from "./EditableDocument"; @@ -69,6 +70,7 @@ interface DocumentEditProps { import ReadOnlyTextEditor from "./editor/read-only-editor/ReadOnlyTextEditor"; import { EditorMode } from "./EditorMode"; +import { TagTokenParser } from "../documents/search/parsers/tag"; /** * This is the main document editing view, which houses the editor and some controls. @@ -94,6 +96,7 @@ const DocumentEditView = observer((props: DocumentEditProps) => { [], ); + // todo: move this to view model function getName(journalId?: string) { const journal = journals?.find((j) => j.id === journalId); return journal ? journal.name : "Unknown journal"; @@ -203,6 +206,34 @@ const DocumentEditView = observer((props: DocumentEditProps) => { } } + function onAddTag(tokens: string[]) { + if (tokens.length > 1) { + // https://evergreen.segment.com/components/tag-input + // Documents say this is single value, Type says array + // Testing says array but with only one value... unsure how multiple + // values end up in the array. + console.warn( + "TagInput.onAdd called with > 1 token? ", + tokens, + "ignoring extra tokens", + ); + } + + let tag = new TagTokenParser().parse(tokens[0])?.value; + if (!tag) return; + + if (!document.tags.includes(tag)) { + document.tags.push(tag); + document.save(); + } + } + + function onRemoveTag(tag: string | React.ReactNode, idx: number) { + if (typeof tag !== "string") return; + document.tags = document.tags.filter((t) => t !== tag); + document.save(); + } + function renderTab(tab: string) { switch (tab) { case EditorMode.Editor: @@ -292,6 +323,25 @@ const DocumentEditView = observer((props: DocumentEditProps) => { {journalPicker()} +
+ +
+ {renderTab(selectedViewMode)} {/* Action buttons */} diff --git a/src/views/edit/useEditableDocument.ts b/src/views/edit/useEditableDocument.ts index c9106bf..8558068 100644 --- a/src/views/edit/useEditableDocument.ts +++ b/src/views/edit/useEditableDocument.ts @@ -47,7 +47,7 @@ export function useEditableDocument( try { // if documentId -> This is an existing document if (documentId) { - const doc = await client.documents.findById({ documentId }); + const doc = await client.documents.findById({ id: documentId }); if (!isEffectMounted) return; setDocument(new EditableDocument(client, doc)); } else {