diff --git a/apps/obsidian/src/components/NodeDisplayOptionsMenu.tsx b/apps/obsidian/src/components/NodeDisplayOptionsMenu.tsx new file mode 100644 index 0000000000..628441ffcc --- /dev/null +++ b/apps/obsidian/src/components/NodeDisplayOptionsMenu.tsx @@ -0,0 +1,51 @@ +import type { App } from "obsidian"; +import type { ReactElement } from "react"; +import { SearchDropdown } from "~/components/SearchDropdown"; +import { activateOnKey } from "~/utils/keyboardHints"; + +export const NodeDisplayOptionsMenu = ({ + app, + isOpen, + onOpenChange, + onShowCandidatesChange, + showCandidates, +}: { + app: App; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onShowCandidatesChange: (showCandidates: boolean) => void; + showCandidates: boolean; +}): ReactElement => { + const toggle = (): void => onShowCandidatesChange(!showCandidates); + + return ( + +
activateOnKey(event, toggle)} + onMouseDown={(event) => event.preventDefault()} + className="text-normal hover:bg-modifier-hover flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm" + > + Show candidate nodes + {/* Obsidian's own toggle chrome, so it matches the settings tab. */} +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index 176aa1eddf..afa9a0acb6 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -4,6 +4,7 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; type NodeSearchFooterProps = { canAct: boolean; canInsertLink: boolean; + isActiveResultLinkable: boolean; onClose: () => void; onInsertLink: () => void; onOpenInNewTab: () => void; @@ -53,6 +54,7 @@ const FooterAction = ({ export const NodeSearchFooter = ({ canAct, canInsertLink, + isActiveResultLinkable, onClose, onInsertLink, onOpenInNewTab, @@ -62,7 +64,7 @@ export const NodeSearchFooter = ({ {/* Absent, not disabled: with no cursor there is nothing to insert into. */} {canInsertLink && ( ( + + {badge.text} + +); + const ResultList = ({ results, activeIndex, @@ -228,7 +254,7 @@ const ResultList = ({ > {results.map((result, index) => (
hasPointerMoved(event) && onActivate(index)} @@ -236,23 +262,28 @@ const ResultList = ({ // Keeps focus in the search input, so the keyboard path stays live // after a click. onMouseDown={(event) => event.preventDefault()} - className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${ + className={`border-modifier-border flex cursor-pointer items-start gap-2 border-b px-3 py-2 ${ index === activeIndex ? "bg-modifier-hover" : "" }`} > - {result.nodeType.badge && ( - - {result.nodeType.badge.text} - - )} - + {/* Fixed-width column, so every title starts at the same x. */} + + {result.nodeType.badge && ( + + )} + +
+ + {result.tagLine && ( +
+ {`#${result.tagLine.tag} · ${result.file.basename} · L${result.tagLine.line + 1}`} +
+ )} +
))}
@@ -280,6 +311,10 @@ const NodeSearch = ({ // One value per toolbar, so two panels can never be open at once. const [openDropdown, setOpenDropdown] = useState(null); const [sortKey, setSortKey] = useState(DEFAULT_SORT_KEY); + const [showCandidates, setShowCandidates] = useState(false); + const [tagCandidates, setTagCandidates] = useState( + [], + ); const [sortDirection, setSortDirection] = useState( DEFAULT_SORT_DIRECTION, ); @@ -322,6 +357,30 @@ const NodeSearch = ({ } }, [app]); + // Rescans on every toggle-on, so node type edits made meanwhile are picked up. + useEffect(() => { + if (!showCandidates) { + setTagCandidates([]); + return; + } + let cancelled = false; + const load = async (): Promise => { + try { + const candidates = await new QueryEngine(app).getCandidateNodes( + plugin.settings.nodeTypes, + ); + if (!cancelled) setTagCandidates(candidates); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not load candidate nodes: ${message}`); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [app, plugin.settings.nodeTypes, showCandidates]); + useEffect(() => { const timeout = window.setTimeout( () => setDebouncedQuery(query), @@ -334,7 +393,7 @@ const NodeSearch = ({ const results = useMemo(() => { if (candidateState.status !== "ready") return []; const ranked = rankDiscourseNodesByTitle({ - candidates: candidateState.candidates, + candidates: [...candidateState.candidates, ...tagCandidates], query: debouncedQuery, nodeTypeIds: selectedNodeTypeIds, }); @@ -368,6 +427,7 @@ const NodeSearch = ({ selectedNodeTypeIds, sortDirection, sortKey, + tagCandidates, userNames, ]); @@ -403,12 +463,12 @@ const NodeSearch = ({ // Closes before opening: `close()` unmounts this React root, so the file and // app are read first and nothing touches state afterwards. const openActiveResult = ( - open: (app: App, file: TFile) => Promise, + open: (app: App, file: TFile, options: { line?: number }) => Promise, ): void => { if (!activeResult) return; - const { file } = activeResult; + const { file, tagLine } = activeResult; onClose(); - void open(app, file).catch((error: unknown) => { + void open(app, file, { line: tagLine?.line }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); new Notice(`Could not open ${file.basename}: ${message}`); }); @@ -426,9 +486,12 @@ const NodeSearch = ({ if (!isOpen) inputRef.current?.focus(); }; + // A candidate is a line, not a node yet, so there is nothing to link to. + const isActiveResultLinkable = !!activeResult && !activeResult.tagLine; + // Closes before inserting, like `openActiveResult`. const insertLinkToActiveResult = (): void => { - if (!activeResult || !insertTarget) return; + if (!activeResult || !insertTarget || !isActiveResultLinkable) return; const { file } = activeResult; onClose(); try { @@ -456,7 +519,7 @@ const NodeSearch = ({ (event.metaKey || event.ctrlKey) && !event.altKey && insertTarget && - activeResult + isActiveResultLinkable ) { event.preventDefault(); insertLinkToActiveResult(); @@ -515,6 +578,15 @@ const NodeSearch = ({ sortDirection={sortDirection} sortKey={sortKey} /> + + handleDropdownOpenChange({ id: "display-options", isOpen }) + } + onShowCandidatesChange={setShowCandidates} + showCandidates={showCandidates} + />
@@ -542,6 +614,7 @@ const NodeSearch = ({ openActiveResult(openFileInNewTab)} diff --git a/apps/obsidian/src/components/NodeSortMenu.tsx b/apps/obsidian/src/components/NodeSortMenu.tsx index ade1326ec5..6840d56f9b 100644 --- a/apps/obsidian/src/components/NodeSortMenu.tsx +++ b/apps/obsidian/src/components/NodeSortMenu.tsx @@ -1,6 +1,7 @@ import { App, setIcon } from "obsidian"; -import type { KeyboardEvent, ReactElement } from "react"; +import type { ReactElement } from "react"; import { SearchDropdown } from "~/components/SearchDropdown"; +import { activateOnKey } from "~/utils/keyboardHints"; import { SORT_OPTIONS, getDefaultDirectionForKey, @@ -16,16 +17,6 @@ const DIRECTIONS: { direction: SortDirection; label: string }[] = [ { direction: "desc", label: "Desc" }, ]; -// Rows are divs, so Enter and Space have to be wired up the way a button gets them free. -const activateOnKey = ( - event: KeyboardEvent, - activate: () => void, -): void => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activate(); -}; - const getDirectionIconName = (direction: SortDirection): string => direction === "asc" ? "arrow-up-narrow-wide" : "arrow-down-wide-narrow"; diff --git a/apps/obsidian/src/components/SearchDropdown.tsx b/apps/obsidian/src/components/SearchDropdown.tsx index d4030164e1..c121961529 100644 --- a/apps/obsidian/src/components/SearchDropdown.tsx +++ b/apps/obsidian/src/components/SearchDropdown.tsx @@ -2,7 +2,11 @@ import { App, Scope, setIcon } from "obsidian"; import { useEffect, useRef, type ReactElement, type ReactNode } from "react"; /** Which toolbar panel is open, so two can never be open at once. */ -export type SearchDropdownId = "type-filter" | "sort" | null; +export type SearchDropdownId = + | "type-filter" + | "sort" + | "display-options" + | null; export const SearchDropdown = ({ app, diff --git a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts index 4dbe521800..f754f78d07 100644 --- a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts @@ -88,17 +88,25 @@ export const openFileInSidebar = async ( export const openFileInNewTab = async ( app: App, file: TFile, + { line }: { line?: number } = {}, ): Promise => { const leaf = app.workspace.getLeaf("tab"); - await leaf.openFile(file); + await leaf.openFile( + file, + line === undefined ? undefined : { eState: { line } }, + ); app.workspace.setActiveLeaf(leaf); }; export const openFileInNewLeaf = async ( app: App, file: TFile, + { line }: { line?: number } = {}, ): Promise => { const leaf = app.workspace.getLeaf("split"); - await leaf.openFile(file); + await leaf.openFile( + file, + line === undefined ? undefined : { eState: { line } }, + ); app.workspace.setActiveLeaf(leaf); }; diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index e817459b11..883fbb0107 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -10,6 +10,7 @@ import { BulkImportPattern, BulkImportCandidate, DiscourseNode } from "~/types"; import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; import { extractContentFromTitle } from "~/utils/extractContentFromTitle"; import { AppWithUnofficialApis } from "~/utils/obsidianUnofficialTypes"; +import { titleFromTaggedLine } from "~/utils/taggedLine"; // This is a workaround to get the datacore API. // TODO: Remove once we can use datacore npm package @@ -45,6 +46,8 @@ export type DiscourseNodeCandidate = { */ title: string; nodeTypeId: string; + /** Set for candidate nodes: an inline line tagged with a node type's tag. */ + tagLine?: { line: number; tag: string }; }; export type RankedDiscourseNode = DiscourseNodeCandidate & { @@ -87,6 +90,58 @@ export class QueryEngine { return candidates; }; + /** One pass over the metadata cache's tag index; only files with a hit are read. */ + getCandidateNodes = async ( + nodeTypes: DiscourseNode[], + ): Promise => { + const nodeTypeByTag = new Map< + string, + { nodeType: DiscourseNode; tag: string } + >(); + for (const nodeType of nodeTypes) { + if (nodeType.tag) { + nodeTypeByTag.set(nodeType.tag.toLowerCase(), { + nodeType, + tag: nodeType.tag, + }); + } + } + if (!nodeTypeByTag.size) return []; + + type TagHit = { line: number; nodeType: DiscourseNode; tag: string }; + const hitsByFile: { file: TFile; hits: TagHit[] }[] = []; + for (const file of this.app.vault.getMarkdownFiles()) { + const hits: TagHit[] = []; + const seen = new Set(); + for (const tagCache of this.app.metadataCache.getFileCache(file)?.tags ?? + []) { + const match = nodeTypeByTag.get(tagCache.tag.slice(1).toLowerCase()); + if (!match) continue; + const line = tagCache.position.start.line; + const key = `${line}:${match.nodeType.id}`; + if (seen.has(key)) continue; + seen.add(key); + hits.push({ line, ...match }); + } + if (hits.length) hitsByFile.push({ file, hits }); + } + + const perFile = await Promise.all( + hitsByFile.map(async ({ file, hits }) => { + // A file deleted or locked mid-scan shouldn't sink the other candidates. + const content = await this.app.vault.cachedRead(file).catch(() => ""); + const lines = content.split("\n"); + return hits.flatMap(({ line, nodeType, tag }) => { + const title = titleFromTaggedLine(lines[line] ?? ""); + return title + ? [{ file, title, nodeTypeId: nodeType.id, tagLine: { line, tag } }] + : []; + }); + }), + ); + return perFile.flat(); + }; + /** * Search across all discourse nodes (files that have frontmatter nodeTypeId) */ @@ -744,6 +799,11 @@ const filterCandidatesByNodeTypeIds = ( return candidates.filter((candidate) => selected.has(candidate.nodeTypeId)); }; +const nodesFirst = ( + a: DiscourseNodeCandidate, + b: DiscourseNodeCandidate, +): number => Number(!!a.tagLine) - Number(!!b.tagLine); + /** * Best match first, uncapped — capping is the caller's, so a later re-sort orders the * whole set rather than a top slice. Filters before scoring: same results, less work. @@ -763,7 +823,7 @@ export const rankDiscourseNodesByTitle = ({ // Filter-only searches still need a list, so an empty query is not an empty result. if (!trimmedQuery) { return [...filtered] - .sort((a, b) => a.title.localeCompare(b.title)) + .sort((a, b) => a.title.localeCompare(b.title) || nodesFirst(a, b)) .map((candidate) => ({ ...candidate, match: { score: 0, matches: [] }, @@ -778,8 +838,9 @@ export const rankDiscourseNodesByTitle = ({ if (match) ranked.push({ ...candidate, match }); } - // Sort is stable, so equal scores keep candidate order. - return ranked.sort((a, b) => b.match.score - a.match.score); + return ranked.sort( + (a, b) => b.match.score - a.match.score || nodesFirst(a, b), + ); }; /** diff --git a/apps/obsidian/src/services/__tests__/QueryEngine.test.ts b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts index 18d30e9c09..ed56c78e35 100644 --- a/apps/obsidian/src/services/__tests__/QueryEngine.test.ts +++ b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts @@ -1,6 +1,22 @@ import { TFile, type App } from "obsidian"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryEngine } from "~/services/QueryEngine"; +import { + QueryEngine, + rankDiscourseNodesByTitle, + type DiscourseNodeCandidate, +} from "~/services/QueryEngine"; +import type { DiscourseNode } from "~/types"; + +// Stand-in scorer: earlier substring hits score higher, mirroring fuzzy search's ordering. +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal()), + prepareFuzzySearch: (query: string) => (text: string) => { + const index = text.toLowerCase().indexOf(query.toLowerCase()); + return index < 0 + ? null + : { score: -index, matches: [[index, index + query.length]] }; + }, +})); type Frontmatter = Record; @@ -175,3 +191,263 @@ describe("QueryEngine Datacore readiness", () => { expect(datacoreQuery).not.toHaveBeenCalled(); }); }); + +type VaultNote = { + path: string; + content: string; + frontmatter?: Frontmatter; +}; + +const CLAIM: DiscourseNode = { + id: "claim", + name: "Claim", + format: "CLM - {content}", + tag: "clm-candidate", + created: 0, + modified: 0, +}; + +const EVIDENCE: DiscourseNode = { + id: "evidence", + name: "Evidence", + format: "EVD - {content}", + tag: "evd-candidate", + created: 0, + modified: 0, +}; + +// Mirrors Obsidian's metadataCache: tags are indexed per line, case preserved. +const createVaultApp = (notes: VaultNote[]) => { + const files = notes.map((note) => createFile(note.path)); + const noteByPath = new Map(notes.map((note) => [note.path, note])); + const cachedRead = vi.fn((file: TFile) => + Promise.resolve(noteByPath.get(file.path)?.content ?? ""), + ); + const app = { + metadataCache: { + getFileCache: (file: TFile) => { + const note = noteByPath.get(file.path); + if (!note) return null; + const tags = note.content.split("\n").flatMap((text, line) => + [...text.matchAll(/#[\w-]+/g)].map((match) => ({ + tag: match[0], + position: { start: { line, col: match.index, offset: 0 } }, + })), + ); + return { frontmatter: note.frontmatter, tags }; + }, + }, + plugins: { plugins: {} }, + vault: { getMarkdownFiles: () => files, cachedRead }, + } as unknown as App; + return { app, cachedRead }; +}; + +describe("QueryEngine.getCandidateNodes", () => { + it("returns only the tagged line of a paragraph as the candidate title", async () => { + const { app } = createVaultApp([ + { + path: "Meeting notes.md", + content: "line 1: blah blah blah blah\nchange line here #clm-candidate", + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates).toEqual([ + expect.objectContaining({ + title: "change line here", + nodeTypeId: "claim", + tagLine: { line: 1, tag: "clm-candidate" }, + }), + ]); + expect(candidates[0]?.file.path).toBe("Meeting notes.md"); + }); + + it("strips list, task, heading and quote markers from candidate titles", async () => { + const { app } = createVaultApp([ + { + path: "Shapes.md", + content: [ + "## Heading claim #clm-candidate", + "- list claim #clm-candidate", + " - nested claim #clm-candidate", + "- [ ] task claim #clm-candidate", + "1. numbered claim #clm-candidate", + "> quoted claim #clm-candidate", + "# Top heading claim #clm-candidate", + ].join("\n"), + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates.map((c) => [c.title, c.tagLine?.line])).toEqual([ + ["Heading claim", 0], + ["list claim", 1], + ["nested claim", 2], + ["task claim", 3], + ["numbered claim", 4], + ["quoted claim", 5], + ["Top heading claim", 6], + ]); + }); + + it("matches node tags case-insensitively and ignores other tags", async () => { + const { app } = createVaultApp([ + { + path: "Journal.md", + content: [ + "Mixed case claim #CLM-Candidate", + "Just a todo #todo", + "Evidence line #evd-candidate", + ].join("\n"), + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates.map((c) => [c.title, c.nodeTypeId])).toEqual([ + ["Mixed case claim", "claim"], + ]); + }); + + it("returns one candidate per node type when a line carries two node tags", async () => { + const { app } = createVaultApp([ + { + path: "Journal.md", + content: "Dual line #clm-candidate #evd-candidate", + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([ + CLAIM, + EVIDENCE, + ]); + + expect(candidates.map((c) => [c.title, c.nodeTypeId])).toEqual([ + ["Dual line", "claim"], + ["Dual line", "evidence"], + ]); + }); + + it("collapses a node tag repeated on the same line into one candidate", async () => { + const { app } = createVaultApp([ + { + path: "Journal.md", + content: "Repeated #clm-candidate and again #clm-candidate", + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates).toHaveLength(1); + }); + + it("drops tagged lines with no text besides markers and tags", async () => { + const { app } = createVaultApp([ + { + path: "Journal.md", + content: ["- #clm-candidate", "Real claim #clm-candidate"].join("\n"), + }, + ]); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates.map((c) => c.title)).toEqual(["Real claim"]); + }); + + it("skips a file that fails to read and still returns the others", async () => { + const { app, cachedRead } = createVaultApp([ + { path: "Broken.md", content: "Lost claim #clm-candidate" }, + { path: "Fine.md", content: "Kept claim #clm-candidate" }, + ]); + cachedRead.mockImplementationOnce(() => + Promise.reject(new Error("read failed")), + ); + + const candidates = await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(candidates.map((c) => c.title)).toEqual(["Kept claim"]); + }); + + it("reads only files that contain a node tag", async () => { + const { app, cachedRead } = createVaultApp([ + { path: "Tagged.md", content: "A claim #clm-candidate" }, + { path: "Untagged.md", content: "Nothing here #todo" }, + ]); + + await new QueryEngine(app).getCandidateNodes([CLAIM]); + + expect(cachedRead.mock.calls.map(([file]) => file.path)).toEqual([ + "Tagged.md", + ]); + }); +}); + +describe("rankDiscourseNodesByTitle with candidate nodes", () => { + const node = ( + title: string, + nodeTypeId = "claim", + ): DiscourseNodeCandidate => ({ + file: createFile(`${title}.md`), + title, + nodeTypeId, + }); + const candidate = ( + title: string, + nodeTypeId = "claim", + ): DiscourseNodeCandidate => ({ + file: createFile("Journal.md"), + title, + nodeTypeId, + tagLine: { line: 3, tag: `${nodeTypeId}-candidate` }, + }); + + it("filters candidates by node type like nodes", () => { + const ranked = rankDiscourseNodesByTitle({ + candidates: [ + node("Sky claim"), + candidate("Sky evidence", "evidence"), + candidate("Sky candidate claim"), + ], + query: "", + nodeTypeIds: ["claim"], + }); + + expect(ranked.map((r) => r.title)).toEqual([ + "Sky candidate claim", + "Sky claim", + ]); + }); + + it("ranks a better-matching candidate above a weaker node", () => { + const ranked = rankDiscourseNodesByTitle({ + candidates: [node("Why the sky is blue"), candidate("Sky varies")], + query: "sky", + }); + + expect(ranked.map((r) => r.title)).toEqual([ + "Sky varies", + "Why the sky is blue", + ]); + }); + + it("puts nodes before candidates on equal scores", () => { + const ranked = rankDiscourseNodesByTitle({ + candidates: [candidate("Sky is blue"), node("Sky is blue")], + query: "sky", + }); + + expect(ranked.map((r) => Boolean(r.tagLine))).toEqual([false, true]); + }); + + it("puts nodes before candidates on equal titles when the query is empty", () => { + const ranked = rankDiscourseNodesByTitle({ + candidates: [candidate("Sky is blue"), node("Sky is blue")], + query: "", + }); + + expect(ranked.map((r) => Boolean(r.tagLine))).toEqual([false, true]); + }); +}); diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts index ec38800a83..291da19e7f 100644 --- a/apps/obsidian/src/utils/keyboardHints.ts +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -1,4 +1,5 @@ import { Platform } from "obsidian"; +import type { KeyboardEvent } from "react"; export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape" | "Tab"; @@ -33,3 +34,13 @@ export const formatHintKeys = ({ export const getHintKeys = (keys: HintKey[]): string[] => formatHintKeys({ keys, isMacOS: Platform.isMacOS }); + +// Rows are divs, so Enter and Space have to be wired up the way a button gets them free. +export const activateOnKey = ( + event: KeyboardEvent, + activate: () => void, +): void => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activate(); +}; diff --git a/apps/obsidian/src/utils/tagNodeHandler.ts b/apps/obsidian/src/utils/tagNodeHandler.ts index 8048437d0a..b207542a7c 100644 --- a/apps/obsidian/src/utils/tagNodeHandler.ts +++ b/apps/obsidian/src/utils/tagNodeHandler.ts @@ -14,6 +14,7 @@ import ModifyNodeModal from "~/components/ModifyNodeModal"; import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUtils"; import { getNodeTagColors } from "./colorUtils"; import { createDiscourseNodeFile, formatNodeName } from "./createNode"; +import { extractListPrefix, titleFromTaggedLine } from "./taggedLine"; const HOVER_DELAY = 200; const HIDE_DELAY = 100; @@ -22,23 +23,8 @@ const STYLE_ELEMENT_ID = "dg-discourse-tag-colors"; const DISCOURSE_TAG_CLASS = "dg-discourse-tag"; const NODE_ID_ATTR = "data-dg-discourse-tag-node"; -const LIST_INDICATOR_REGEX = /^(\s*)(\d+[.)]\s+|[-*+]\s+(?:\[[ xX]\]\s+)?)/; - const TAG_SEGMENT_PREFIX = "tag-"; -const sanitizeTitle = (title: string): string => - title - .replace(LIST_INDICATOR_REGEX, "") - .replace(/[\\/:]/g, "") - .replace(/\s+/g, " ") - .trim(); - -const extractListPrefix = (line: string): string => - line.match(LIST_INDICATOR_REGEX)?.[0] ?? ""; - -const titleFromTaggedLine = (lineText: string): string => - sanitizeTitle(lineText.replace(/#[^\s]+/g, "")); - // Nodes are named like `hashtag_hashtag-end_meta_tag-clm-candidate`; reading the tag // from the tree inherits Obsidian's rules for code blocks, URLs and headings. const tagNameFromSyntaxNode = (nodeName: string): string | null => { diff --git a/apps/obsidian/src/utils/taggedLine.ts b/apps/obsidian/src/utils/taggedLine.ts new file mode 100644 index 0000000000..c3b2824863 --- /dev/null +++ b/apps/obsidian/src/utils/taggedLine.ts @@ -0,0 +1,18 @@ +const LIST_INDICATOR_REGEX = /^(\s*)(\d+[.)]\s+|[-*+]\s+(?:\[[ xX]\]\s+)?)/; + +const BLOCK_MARKER_REGEX = /^\s*(?:#{1,6}\s+|>\s*)/; + +const sanitizeTitle = (title: string): string => + title + .replace(LIST_INDICATOR_REGEX, "") + .replace(/[\\/:]/g, "") + .replace(/\s+/g, " ") + .trim(); + +export const extractListPrefix = (line: string): string => + line.match(LIST_INDICATOR_REGEX)?.[0] ?? ""; + +export const titleFromTaggedLine = (lineText: string): string => + sanitizeTitle( + lineText.replace(BLOCK_MARKER_REGEX, "").replace(/#[^\s]+/g, ""), + );