From 1cf4508be2ee6ac52d22a8e7c17fb5728ddafbf0 Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Mon, 8 Jan 2024 14:29:38 +0100 Subject: [PATCH 1/3] feat(core): enrich_tfile utility function --- src/std/index.ts | 10 ++++- src/utils/files.ts | 92 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/std/index.ts b/src/std/index.ts index 11fb673b..817a012d 100644 --- a/src/std/index.ts +++ b/src/std/index.ts @@ -8,6 +8,7 @@ import { filter, compact, filterMap, + flatten, } from "fp-ts/Array"; import { map as mapO, @@ -17,6 +18,8 @@ import { fromNullable as fromNullableOpt, fold as ofold, chain as ochain, + alt as OAlt, + fromPredicate, } from "fp-ts/Option"; import { isLeft, @@ -53,6 +56,7 @@ export const A = { map: mapArr, filter, filterMap, + flatten, }; /** * Non empty array @@ -89,6 +93,8 @@ export const O = { fold: ofold, fromNullable: fromNullableOpt, chain: ochain, + fromPredicate: fromPredicate, + alt: OAlt, }; export const parse = tryCatchK(parseV, (e: unknown) => e as ValiError); @@ -110,7 +116,9 @@ export type ParsingFn = (input: unknown) => Either into one. * If the first function returns a Right, the second function is not called. */ -class _EFunSemigroup implements Semigroup> { +class _EFunSemigroup + implements Semigroup> +{ concat(f: ParsingFn, g: ParsingFn): (i: unknown) => Either { return (i) => { const fRes = f(i); diff --git a/src/utils/files.ts b/src/utils/files.ts index bc5078c1..341f73ab 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,5 +1,6 @@ -import { App, TAbstractFile, TFile, TFolder, Vault, normalizePath } from "obsidian"; -import { E, Either, pipe } from "@std"; +import * as S from "fp-ts/string"; +import { App, CachedMetadata, TAbstractFile, TFile, TFolder, Vault, normalizePath } from "obsidian"; +import { E, Either, O, pipe, A } from "@std"; export class FolderDoesNotExistError extends Error { static readonly tag = "FolderDoesNotExistError"; } @@ -36,11 +37,14 @@ export function resolve_tfolder(folder_str: string, app: App): Either { +export function resolve_tfile( + file_str: string, + app: App, +): Either { return pipe( normalizePath(file_str), (path) => app.vault.getAbstractFileByPath(path), @@ -50,11 +54,14 @@ export function resolve_tfile(file_str: string, app: App): Either> { +export function get_tfiles_from_folder( + folder_str: string, + app: App, +): Either> { return pipe( resolve_tfolder(folder_str, app), E.flatMap((folder) => { @@ -70,14 +77,77 @@ export function get_tfiles_from_folder(folder_str: string, app: App): Either { return a.basename.localeCompare(b.basename); }); - } - )) + }), + ); +} + +function isArrayOfStrings(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +const splitIfString = (value: unknown) => + pipe( + value, + O.fromPredicate(S.isString), + O.map((s) => s.split(",")), + ); + +export function parseToArrOfStr(str: unknown) { + return pipe( + str, + O.fromNullable, + O.chain((value) => + pipe( + value, + splitIfString, + /* prettier-ignore */ + O.alt(() => pipe( + value, + O.fromPredicate(isArrayOfStrings))), + ), + ), + ); +} +function extract_tags(cache: CachedMetadata): string[] { + /* prettier-ignore */ + const bodyTags = pipe( + cache.tags, + O.fromNullable, + O.map(A.map((tag) => tag.tag))); + + const frontmatterTags = pipe( + cache.frontmatter, + O.fromNullable, + O.chain((frontmatter) => parseToArrOfStr(frontmatter.tags)), + ); + /* prettier-ignore */ + return pipe( + [bodyTags, frontmatterTags], + A.compact, + A.flatten); +} + +export function enrich_tfile( + file: TFile, + app: App, +): TFile & { frontmatter: Record; tags: string[] } { + const metadata = app.metadataCache.getCache(file.path); + return { + ...file, + frontmatter: metadata?.frontmatter ?? {}, + tags: pipe( + metadata, + O.fromNullable, + O.map(extract_tags), + O.getOrElse(() => [] as string[]), + ), + }; } export function file_exists(file_str: string, app: App): boolean { return pipe( normalizePath(file_str), (path) => app.vault.getAbstractFileByPath(path), - (value) => value !== null - ) + (value) => value !== null, + ); } From 38dc4b3060b089bc8829bde2f34ff198830a283e Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Mon, 8 Jan 2024 17:49:21 +0100 Subject: [PATCH 2/3] feat(form): the input file is now fuzzy over path and tags. It shows a nicer UI that includes the path --- src/suggesters/suggestFile.ts | 57 ++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/suggesters/suggestFile.ts b/src/suggesters/suggestFile.ts index 19a87e31..3e54a558 100644 --- a/src/suggesters/suggestFile.ts +++ b/src/suggesters/suggestFile.ts @@ -1,16 +1,16 @@ -import { AbstractInputSuggest, App, TAbstractFile, TFile } from "obsidian"; -import { get_tfiles_from_folder } from "../utils/files"; -import { E } from "@std"; +import { AbstractInputSuggest, App, TFile } from "obsidian"; +import { enrich_tfile, get_tfiles_from_folder } from "../utils/files"; +import { E, pipe, A } from "@std"; +import Fuse from "fuse.js"; // Instead of hardcoding the logic in separate and almost identical classes, // we move this little logic parts into an interface and we can use the samme // input type and configure it to render file-like, note-like, or whatever we want. export interface FileStrategy { - renderSuggestion(file: TFile): string; + renderSuggestion(file: TFile): string | DocumentFragment; selectSuggestion(file: TFile): string; } - export class FileSuggest extends AbstractInputSuggest { constructor( public app: App, @@ -22,24 +22,52 @@ export class FileSuggest extends AbstractInputSuggest { } getSuggestions(input_str: string): TFile[] { - const all_files = get_tfiles_from_folder(this.folder, this.app) + const all_files = pipe( + get_tfiles_from_folder(this.folder, this.app), + E.map(A.map((file) => enrich_tfile(file, this.app))), + ); if (E.isLeft(all_files)) { return []; } const lower_input_str = input_str.toLowerCase(); - - return all_files.right.filter((file: TAbstractFile) => { - return ( - file instanceof TFile && - file.extension === "md" && - file.path.toLowerCase().contains(lower_input_str) - ); + if (input_str === "") return all_files.right; + const fuse = new Fuse(all_files.right, { + keys: ["path", "tags", "frontmatter"], }); + return fuse.search(lower_input_str).map((result) => result.item); } + /* This is an example structure of how a obsidian suggestion looks like in the dom +
+
+
+
+ Fátima +
+
Fátima
+
+
+ + + + + + +
+
+
+In the renderSuggestion the `el` is the suggestion-item div +*/ renderSuggestion(file: TFile, el: HTMLElement): void { - el.setText(this.strategy.renderSuggestion(file)); + const text = this.strategy.renderSuggestion(file); + el.addClasses(["mod-complex"]); + const title = el.createDiv({ cls: "suggestion-title", text: text }); + const note = el.createDiv({ cls: "suggestion-note", text: file.parent?.path }); + const body = el.createDiv({ cls: "suggestion-content" }); + body.appendChild(title); + body.appendChild(note); + el.appendChild(body); } selectSuggestion(file: TFile): void { @@ -48,4 +76,3 @@ export class FileSuggest extends AbstractInputSuggest { this.close(); } } - From 6409ebf217730f33b223e7e6b03d5f7a70abe8ac Mon Sep 17 00:00:00 2001 From: Danielo Rodriguez Date: Sat, 13 Jan 2024 16:55:27 +0100 Subject: [PATCH 3/3] feat(input): note suggest shows the parent folder of the note --- src/suggesters/suggestFile.ts | 26 +++++++++++++++++++++----- styles.css | 6 ++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/suggesters/suggestFile.ts b/src/suggesters/suggestFile.ts index 3e54a558..6cf3d5b9 100644 --- a/src/suggesters/suggestFile.ts +++ b/src/suggesters/suggestFile.ts @@ -1,4 +1,4 @@ -import { AbstractInputSuggest, App, TFile } from "obsidian"; +import { AbstractInputSuggest, App, TFile, setIcon } from "obsidian"; import { enrich_tfile, get_tfiles_from_folder } from "../utils/files"; import { E, pipe, A } from "@std"; import Fuse from "fuse.js"; @@ -33,9 +33,19 @@ export class FileSuggest extends AbstractInputSuggest { const lower_input_str = input_str.toLowerCase(); if (input_str === "") return all_files.right; const fuse = new Fuse(all_files.right, { - keys: ["path", "tags", "frontmatter"], + includeMatches: false, + includeScore: true, + shouldSort: true, + keys: [ + { name: "path", weight: 2 }, + { name: "tags", weight: 1 }, + { name: "frontmatter.aliases", weight: 2 }, + ], + }); + return fuse.search(lower_input_str).map((result) => { + //console.log(result); + return result.item; }); - return fuse.search(lower_input_str).map((result) => result.item); } /* This is an example structure of how a obsidian suggestion looks like in the dom @@ -63,10 +73,16 @@ In the renderSuggestion the `el` is the suggestion-item div const text = this.strategy.renderSuggestion(file); el.addClasses(["mod-complex"]); const title = el.createDiv({ cls: "suggestion-title", text: text }); - const note = el.createDiv({ cls: "suggestion-note", text: file.parent?.path }); + const subtitle = el.createDiv({ + cls: "suggestion-note modal-form-suggestion", + text: file.parent?.path, + }); + const icon = el.createSpan({ cls: "suggestion-icon" }); + setIcon(icon, "folder"); + subtitle.prepend(icon); const body = el.createDiv({ cls: "suggestion-content" }); body.appendChild(title); - body.appendChild(note); + body.appendChild(subtitle); el.appendChild(body); } diff --git a/styles.css b/styles.css index 701eec35..9612518c 100644 --- a/styles.css +++ b/styles.css @@ -89,3 +89,9 @@ If your plugin does not need CSS, delete this file. color: var(--text-error); font-weight: bold; } +.modal-form-suggestion { + display: flex; + gap: 8px; + padding: 4px 0; + align-items: center; +}