diff --git a/package.json b/package.json index 436a740..7fa0232 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@0xgg/echomd": "^1.0.0", + "@0xgg/echomd": "^1.0.1", "@dicebear/avatars": "^4.0.8", "@dicebear/avatars-female-sprites": "^4.0.8", "@dicebear/avatars-human-sprites": "^4.0.8", diff --git a/src/components/NoteAliasPopover.tsx b/src/components/NoteAliasPopover.tsx new file mode 100644 index 0000000..41beff1 --- /dev/null +++ b/src/components/NoteAliasPopover.tsx @@ -0,0 +1,112 @@ +import { + Box, + IconButton, + List, + ListItem, + Popover, + TextField, + Typography, +} from "@material-ui/core"; +import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; +import clsx from "clsx"; +import { TrashCan } from "mdi-material-ui"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + menuItemOverride: { + "cursor": "default", + "padding": `0 0 0 ${theme.spacing(2)}px`, + "&:hover": { + backgroundColor: "inherit", + }, + }, + menuItemTextField: { + paddingRight: theme.spacing(2), + }, + }), +); +interface Props { + anchorElement: HTMLElement; + onClose: () => void; + addAlias: (alias: string) => void; + deleteAlias: (alias: string) => void; + aliases: string[]; +} +export function NoteAliasPopover(props: Props) { + const classes = useStyles(props); + const { t } = useTranslation(); + const [alias, setAlias] = useState(""); + + const addAlias = useCallback( + (alias: string) => { + if (!alias || alias.trim().length === 0) { + return; + } + props.addAlias(alias); + setAlias(""); + }, + [props], + ); + + useEffect(() => { + setAlias(""); + }, [props.anchorElement]); + + return ( + + + + { + if (event.which === 13) { + addAlias(alias); + } + }} + onChange={(event) => setAlias(event.target.value)} + value={alias} + > + + {props.aliases.length > 0 ? ( + props.aliases.map((alias) => { + return ( + + + {alias} + props.deleteAlias(alias)}> + + + + + ); + }) + ) : ( + + + {t("general/no-aliases")} + + + )} + + + ); +} diff --git a/src/components/NotePanel.tsx b/src/components/NotePanel.tsx index 6337f68..2affeb4 100644 --- a/src/components/NotePanel.tsx +++ b/src/components/NotePanel.tsx @@ -601,12 +601,10 @@ export default function NotePanel(props: Props) { const removeHighlightClass = () => { element.classList.remove("reference-highlight"); }; - previewElement.current.addEventListener( - "click", - removeHighlightClass, - ); + const previewElementCurrent = previewElement.current; + previewElementCurrent.addEventListener("click", removeHighlightClass); return () => { - previewElement.current.removeEventListener( + previewElementCurrent.removeEventListener( "click", removeHighlightClass, ); @@ -1058,6 +1056,7 @@ export default function NotePanel(props: Props) { ); return result["title"] + ".md" === filePath; }, + fields: ["title"], }); const commands: { text: string; @@ -1105,24 +1104,43 @@ export default function NotePanel(props: Props) { .replace(/^\[+/, ""); const searchResults = props.notebook.search.search(currentWord, { fuzzy: true, + fields: ["title", "aliases", "filePath"], }); const commands: { text: string; displayText: string; - }[] = searchResults.map((searchResult: any) => { + }[] = []; + for (let i = 0; i < searchResults.length; i++) { + const searchResult = searchResults[i]; const filtPath = path.relative( path.dirname(path.join(note.notebookPath, note.filePath)), path.join(note.notebookPath, searchResult.filePath), ); - const val = - searchResult.title + ".md" === filtPath - ? `[[${searchResult.title}]]` - : `[[${filtPath}|${searchResult.title}]]`; - return { - text: val, - displayText: val, - }; - }); + const aliases = searchResult.aliases + .split("|") + .concat(searchResult.title) as string[]; + const terms = searchResult.terms; + aliases.forEach((alias) => { + let find = true; + for (let i = 0; i < terms.length; i++) { + const lower = alias.toLocaleLowerCase(); + if (lower.indexOf(terms[i]) < 0) { + find = false; + break; + } + } + if (find) { + const val = + alias + ".md" === filtPath + ? `[[${alias}]]` + : `[[${filtPath}|${alias}]]`; + commands.push({ + text: val, + displayText: val, + }); + } + }); + } return { list: commands, from: { line, ch: start - 1 }, @@ -1202,12 +1220,13 @@ export default function NotePanel(props: Props) { }; editor.on("changes", update); - tocElement.current.addEventListener("click", tocClick, true); + const tocElementCurrent = tocElement.current; + tocElementCurrent.addEventListener("click", tocClick, true); update(); return () => { editor.off("changes", update); - tocElement.current.removeEventListener("click", tocClick); + tocElementCurrent.removeEventListener("click", tocClick); }; }, [editor, editorMode, previewElement, tocElement, note, tocEnabled]); diff --git a/src/components/NotePopover.tsx b/src/components/NotePopover.tsx index 11ce25b..b0630fd 100644 --- a/src/components/NotePopover.tsx +++ b/src/components/NotePopover.tsx @@ -31,6 +31,7 @@ import { ShareVariant, Star, StarOutline, + TooltipEdit, } from "mdi-material-ui"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -42,6 +43,7 @@ import { printPreview } from "../utilities/preview"; import { copyToClipboard } from "../utilities/utils"; import ChangeFilePathDialog from "./ChangeFilePathDialog"; import { DeleteNoteDialog } from "./DeleteNoteDialog"; +import { NoteAliasPopover } from "./NoteAliasPopover"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -75,6 +77,8 @@ export default function NotePopover(props: Props) { ] = useState(false); const [needsToPrint, setNeedsToPrint] = useState(false); const [shareAnchorEl, setShareAnchorEl] = useState(null); + const [noteAliasAnchorEl, setNoteAliasAnchorEl] = useState(null); + const [aliases, setAliases] = useState(note.config.aliases); const theme = useTheme(); const { t } = useTranslation(); const crossnoteContainer = CrossnoteContainer.useContainer(); @@ -225,6 +229,15 @@ export default function NotePopover(props: Props) { primary={t("general/change-file-path")} > + setNoteAliasAnchorEl(event.currentTarget)} + > + + + + + { @@ -295,6 +308,33 @@ export default function NotePopover(props: Props) { tabNode={props.tabNode} note={note} > + { + setNoteAliasAnchorEl(null); + }} + addAlias={(alias) => { + crossnoteContainer + .addNoteAlias( + props.tabNode, + note.notebookPath, + note.filePath, + alias, + ) + .then((aliases) => setAliases(aliases)); + }} + deleteAlias={(alias) => { + crossnoteContainer + .deleteNoteAlias( + props.tabNode, + note.notebookPath, + note.filePath, + alias, + ) + .then((aliases) => setAliases(aliases)); + }} + aliases={aliases} + > { + const notebook = getNotebookAtPath(notebookPath); + if (!notebook) { + return; + } + const note = await notebook.getNote(noteFilePath); + const aliases: string[] = note.config.aliases || []; + const newAliases = aliases.concat(alias); + const newConfig = Object.assign({}, note.config, { + aliases: newAliases, + }); + await updateNoteConfig(tabNode, notebookPath, noteFilePath, newConfig); + notebook.search.addAlias(noteFilePath, alias); + return newAliases; + }, + [getNotebookAtPath, updateNoteConfig], + ); + + const deleteNoteAlias = useCallback( + async ( + tabNode: TabNode, + notebookPath: string, + noteFilePath: string, + alias: string, + ) => { + const notebook = getNotebookAtPath(notebookPath); + if (!notebook) { + return; + } + const note = await notebook.getNote(noteFilePath); + const aliases: string[] = note.config.aliases || []; + const newAliases = aliases.filter((a) => a !== alias); + const newConfig = Object.assign({}, note.config, { + aliases: newAliases, + }); + if (!newAliases.length) { + delete newConfig.aliases; + } + await updateNoteConfig(tabNode, notebookPath, noteFilePath, newConfig); + notebook.search.deleteAlias(noteFilePath, alias); + return newAliases; + }, + [getNotebookAtPath, updateNoteConfig], + ); + const getStatus = useCallback( async (notebookPath: string, filePath: string) => { const notebook = getNotebookAtPath(notebookPath); @@ -760,6 +811,8 @@ Please also check the [Explore](https://crossnote.app/explore) section to discov openLocalNotebook, togglePin, toggleFavorite, + addNoteAlias, + deleteNoteAlias, isAddingNotebook, isPushingNotebook, isPullingNotebook, diff --git a/src/i18n/lang/enUS.ts b/src/i18n/lang/enUS.ts index 2df0bbe..94230b7 100644 --- a/src/i18n/lang/enUS.ts +++ b/src/i18n/lang/enUS.ts @@ -51,6 +51,9 @@ export const enUS = { "general/echomd": "Edit", "general/tags": "Tags", "general/add-a-tag": "Add a tag", + "general/edit-note-alias": "Edit note alias", + "general/no-aliases": "No aliases", + "general/add-an-alias": "Add an alias", "general/private": "Private", "general/public": "Public", "general/friends-only": "Friends only", diff --git a/src/i18n/lang/jaJP.ts b/src/i18n/lang/jaJP.ts index 44aaa18..13bfcce 100644 --- a/src/i18n/lang/jaJP.ts +++ b/src/i18n/lang/jaJP.ts @@ -51,6 +51,9 @@ export const jaJP = { "general/echomd": "編集", "general/tags": "タグ", "general/add-a-tag": "タグを追加する", + "general/edit-note-alias": "ノート エイリアスの編集", + "general/no-aliases": "エイリアスなし", + "general/add-an-alias": "エイリアスを追加する", "general/private": "民間", "general/public": "公開可見", "general/friends-only": "友達にのみ表示", diff --git a/src/i18n/lang/zhCN.ts b/src/i18n/lang/zhCN.ts index 78cfa4d..3b73989 100644 --- a/src/i18n/lang/zhCN.ts +++ b/src/i18n/lang/zhCN.ts @@ -51,6 +51,9 @@ export const zhCN = { "general/echomd": "编辑", "general/tags": "标签", "general/add-a-tag": "添加一个标签", + "general/edit-note-alias": "编辑笔记别名", + "general/no-aliases": "没有别名", + "general/add-an-alias": "添加一个别名", "general/private": "私有", "general/public": "公开可见", "general/friends-only": "仅好友可见", diff --git a/src/i18n/lang/zhTW.ts b/src/i18n/lang/zhTW.ts index 57b13fc..ef058f8 100644 --- a/src/i18n/lang/zhTW.ts +++ b/src/i18n/lang/zhTW.ts @@ -51,6 +51,9 @@ export const zhTW = { "general/echomd": "編輯", "general/tags": "標籤", "general/add-a-tag": "添加一個標籤", + "general/edit-note-alias": "編輯筆記別名", + "general/no-aliases": "沒有別名", + "general/add-an-alias": "添加壹個別名", "general/private": "私有", "general/public": "公開可見", "general/friends-only": "僅好友可見", diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts index 40167ba..346227c 100644 --- a/src/lib/keymap.ts +++ b/src/lib/keymap.ts @@ -1,5 +1,5 @@ export enum KeyMap { - "DEFAULT" = "hypermd", + "DEFAULT" = "sublime", "VIM" = "vim", "EMACS" = "emacs", "SUBLIME" = "sublime", diff --git a/src/lib/note.ts b/src/lib/note.ts index 6f02e6a..f24553b 100644 --- a/src/lib/note.ts +++ b/src/lib/note.ts @@ -10,6 +10,7 @@ export interface NoteConfig { modifiedAt: Date; pinned?: boolean; favorited?: boolean; + aliases?: string[]; } export type Mentions = { [key: string]: boolean }; diff --git a/src/lib/notebook.ts b/src/lib/notebook.ts index 696cafd..b59bace 100644 --- a/src/lib/notebook.ts +++ b/src/lib/notebook.ts @@ -162,6 +162,30 @@ export class Notebook { parentToken, token, }); + } else if ( + token.type === "link_open" && + tokens[i + 1] && + tokens[i + 1].type === "text" + ) { + if (token.attrs.length && token.attrs[0][0] === "href") { + const link = token.attrs[0][1]; + const text = tokens[i + 1].content.trim(); + if ( + link.match(/https?:\/\//) || + !text.length || + !link.endsWith(".md") + ) { + // TODO: Ignore more protocols + continue; + } + results.push({ + elementId: token.attrGet("id") || "", // token.meta, // TODO: This is not working yet + text, + link: resolveLink(link), + parentToken, + token, + }); + } } else if (token.children && token.children.length) { traverse(token.children, token, results, level + 1); } @@ -202,7 +226,7 @@ export class Notebook { } await this.removeNoteRelations(oldFilePath); - this.search.removeAll(oldFilePath); + this.search.remove(oldFilePath); // git related works const newDirPath = path.dirname(path.resolve(this.dir, newFilePath)); @@ -279,9 +303,9 @@ export class Notebook { // Read the noteConfig, which is like at the end of the markdown file let noteConfig: NoteConfig = { - // id: "", createdAt: new Date(stats.ctimeMs), modifiedAt: new Date(stats.mtimeMs), + aliases: [], }; try { @@ -305,6 +329,10 @@ export class Notebook { noteConfig.favorited = data.data["favorited"]; delete frontMatter["favorited"]; } + if (data.data["aliases"]) { + noteConfig.aliases = data.data["aliases"]; + delete frontMatter["aliases"]; + } // markdown = matter.stringify(data.content, frontMatter); // <= NOTE: I think gray-matter has bug. Although I delete "note" section from front-matter, it still includes it. markdown = matterStringify(data.content, frontMatter); @@ -333,7 +361,7 @@ export class Notebook { if (refreshNoteRelations) { this.notes[note.filePath] = note; - this.search.add(note.filePath, note.title); + this.search.add(note.filePath, note.title, note.config.aliases); await this.processNoteMentionsAndMentionedBy(note.filePath); } @@ -388,7 +416,7 @@ export class Notebook { const note = await this.getNote(path.relative(this.dir, absFilePath)); if (note) { this.notes[note.filePath] = note; - this.search.add(note.filePath, note.title); + this.search.add(note.filePath, note.title, note.config.aliases); } let stats; @@ -476,7 +504,7 @@ export class Notebook { }); } await this.removeNoteRelations(filePath); - this.search.removeAll(filePath); + this.search.remove(filePath); } } diff --git a/src/lib/search.ts b/src/lib/search.ts index 0a5a620..e63a668 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,77 +1,86 @@ +import { uslug } from "@0xgg/echomd/preview/heading-id-generator"; import MiniSearch, { SearchOptions, SearchResult } from "minisearch"; export interface SearchDoc { id: string; title: string; filePath: string; + aliases: string[]; } export default class Search { public miniSearch: MiniSearch; /** - * filePath -> title -> SearchDoc + * filePath -> SearchDoc */ - private _cache: { [key: string]: { [key: string]: SearchDoc } }; + private _cache: { [key: string]: SearchDoc }; constructor() { this.miniSearch = new MiniSearch({ - fields: ["title", "filePath"], - storeFields: ["title", "filePath"], + fields: ["title", "aliases", "filePath"], + storeFields: ["title", "aliases", "filePath"], + extractField: (document, fieldName) => { + if (fieldName === "aliases") { + return document["aliases"].join("|"); + } else { + return (document as any)[fieldName]; + } + }, tokenize: (string) => { - return ( - string - .replace(/[!@#$%^&*()[\]{},.?/\\=+\-_,。=()【】]/g, " ") - .match(/([^\x00-\x7F]|\w+)/g) || [] - ); + return uslug(string, " ").match(/([^\x00-\x7F]|\w+)/g) || []; }, }); this._cache = {}; } - add(filePath: string, title: string) { + add(filePath: string, title: string, aliases: string[]) { if (!(filePath in this._cache)) { - this._cache[filePath] = {}; - } - if (title in this._cache[filePath]) { + const searchDoc = { + id: filePath + "#" + title, + filePath, + title, + aliases, + }; + this.miniSearch.add(searchDoc); + this._cache[filePath] = searchDoc; + } else { return; } - - const searchDoc = { - id: filePath + "#" + title, - filePath, - title, - }; - this.miniSearch.add(searchDoc); - this._cache[filePath][title] = searchDoc; } - remove(filePath: string, title: string) { + remove(filePath: string) { if (filePath in this._cache) { - const c = this._cache[filePath]; - const doc = c[title]; + const doc = this._cache[filePath]; if (doc) { this.miniSearch.remove(doc); } - delete c[title]; + delete this._cache[filePath]; } } - removeAll(filePath: string) { - if (filePath in this._cache) { - const docs: SearchDoc[] = []; - const c = this._cache[filePath]; - for (const title in c) { - const doc = c[title]; - if (doc) { - docs.push(doc); - } - delete c[title]; - } - this.miniSearch.removeAll(docs); + search(queryString: string, options?: SearchOptions): SearchResult[] { + return this.miniSearch.search(queryString, options); + } + + addAlias(filePath: string, alias: string) { + const searchDoc = this._cache[filePath]; + if (!searchDoc) { + return; } + this.remove(filePath); + this.add(filePath, searchDoc.title, searchDoc.aliases.concat(alias)); } - search(queryString: string, options?: SearchOptions): SearchResult[] { - return this.miniSearch.search(queryString, options); + deleteAlias(filePath: string, alias: string) { + const searchDoc = this._cache[filePath]; + if (!searchDoc) { + return; + } + this.remove(filePath); + this.add( + filePath, + searchDoc.title, + searchDoc.aliases.filter((a) => a !== alias), + ); } } diff --git a/yarn.lock b/yarn.lock index fafbfa1..ba4b2f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@0xgg/echomd@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@0xgg/echomd/-/echomd-1.0.0.tgz#1300b0c979a4d68634f73c05bd74202de4d903f0" - integrity sha512-6wrz7CtPvhwOuukOCQvXFGqYHFr1EdTGm/xYxwEol6jQ3Nckfk1hyCHcYzR3iHuVhvbFTq0mI1Ay0q6CvfTj5Q== +"@0xgg/echomd@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@0xgg/echomd/-/echomd-1.0.1.tgz#8748fb98eb287108344846111158c8c5bbf5efaf" + integrity sha512-1xkP7uCXKxtVZ+aCQAGMn/NvS7znP5H7MSdyJJHVNaRid+67YEyzVFGb5oqsNqb2zcc1og9RCRMGjjPiL4NKrA== optionalDependencies: echarts "^5.0.2" emojione "^4.5.0" @@ -17,6 +17,7 @@ markdown-it "^12.0.4" markdown-it-emoji "^2.0.0" markdown-it-footnote "^3.0.2" + markdown-it-for-inline "^0.1.1" markdown-it-ins "^3.0.1" markdown-it-mark "^3.0.1" markdown-it-sub "^1.0.0" @@ -12435,6 +12436,11 @@ markdown-it-footnote@^3.0.2: resolved "https://registry.npm.taobao.org/markdown-it-footnote/download/markdown-it-footnote-3.0.2.tgz#1575ee7a093648d4e096aa33386b058d92ac8bc1" integrity sha1-FXXuegk2SNTglqozOGsFjZKsi8E= +markdown-it-for-inline@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/markdown-it-for-inline/-/markdown-it-for-inline-0.1.1.tgz#435f2316f5b5e68e1450cfa2242f2b8d59adc75f" + integrity sha1-Q18jFvW15o4UUM+iJC8rjVmtx18= + markdown-it-ins@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/markdown-it-ins/-/markdown-it-ins-3.0.1.tgz#c09356b917cf1dbf73add0b275d67ab8c73d4b4d"