Skip to content

Commit

Permalink
Merge pull request #204 from danielo515/feat/fuzzy-file-input
Browse files Browse the repository at this point in the history
Feat/fuzzy-file-input
  • Loading branch information
danielo515 authored Jan 13, 2024
2 parents 10c6ff4 + 6409ebf commit f030e7e
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 27 deletions.
10 changes: 9 additions & 1 deletion src/std/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
filter,
compact,
filterMap,
flatten,
} from "fp-ts/Array";
import {
map as mapO,
Expand All @@ -17,6 +18,8 @@ import {
fromNullable as fromNullableOpt,
fold as ofold,
chain as ochain,
alt as OAlt,
fromPredicate,
} from "fp-ts/Option";
import {
isLeft,
Expand Down Expand Up @@ -53,6 +56,7 @@ export const A = {
map: mapArr,
filter,
filterMap,
flatten,
};
/**
* Non empty array
Expand Down Expand Up @@ -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);
Expand All @@ -110,7 +116,9 @@ export type ParsingFn<S extends BaseSchema> = (input: unknown) => Either<ValiErr
* Concatenates two parsing functions that return Either<ValiError, B> into one.
* If the first function returns a Right, the second function is not called.
*/
class _EFunSemigroup<A extends BaseSchema, B extends BaseSchema> implements Semigroup<ParsingFn<A>> {
class _EFunSemigroup<A extends BaseSchema, B extends BaseSchema>
implements Semigroup<ParsingFn<A>>
{
concat(f: ParsingFn<A>, g: ParsingFn<B>): (i: unknown) => Either<ValiError, unknown> {
return (i) => {
const fRes = f(i);
Expand Down
73 changes: 58 additions & 15 deletions src/suggesters/suggestFile.ts
Original file line number Diff line number Diff line change
@@ -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, setIcon } 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<TFile> {
constructor(
public app: App,
Expand All @@ -22,24 +22,68 @@ export class FileSuggest extends AbstractInputSuggest<TFile> {
}

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, {
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;
});
}

/* This is an example structure of how a obsidian suggestion looks like in the dom
<div class="suggestion">
<div class="suggestion-item mod-complex is-selected">
<div class="suggestion-content">
<div class="suggestion-title">
<span class="suggestion-highlight">Fátima</span>
</div>
<div class="suggestion-note">Fátima</div>
</div>
<div class="suggestion-aux">
<span class="suggestion-flair" aria-label="Alias">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-forward">
<polyline points="15 17 20 12 15 7"></polyline>
<path d="M4 18v-2a4 4 0 0 1 4-4h12"></path>
</svg>
</span>
</div>
</div>
</div>
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 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(subtitle);
el.appendChild(body);
}

selectSuggestion(file: TFile): void {
Expand All @@ -48,4 +92,3 @@ export class FileSuggest extends AbstractInputSuggest<TFile> {
this.close();
}
}

92 changes: 81 additions & 11 deletions src/utils/files.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Expand Down Expand Up @@ -36,11 +37,14 @@ export function resolve_tfolder(folder_str: string, app: App): Either<FolderErro
return E.left(new NotAFolderError(file));
}
return E.right(file);
})
}),
);
}

export function resolve_tfile(file_str: string, app: App): Either<FileDoesNotExistError | NotAFileError, TFile> {
export function resolve_tfile(
file_str: string,
app: App,
): Either<FileDoesNotExistError | NotAFileError, TFile> {
return pipe(
normalizePath(file_str),
(path) => app.vault.getAbstractFileByPath(path),
Expand All @@ -50,11 +54,14 @@ export function resolve_tfile(file_str: string, app: App): Either<FileDoesNotExi
return E.left(new NotAFileError(file));
}
return E.right(file);
})
)
}),
);
}

export function get_tfiles_from_folder(folder_str: string, app: App): Either<FolderError, Array<TFile>> {
export function get_tfiles_from_folder(
folder_str: string,
app: App,
): Either<FolderError, Array<TFile>> {
return pipe(
resolve_tfolder(folder_str, app),
E.flatMap((folder) => {
Expand All @@ -70,14 +77,77 @@ export function get_tfiles_from_folder(folder_str: string, app: App): Either<Fol
return files.sort((a, b) => {
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<string, unknown>; 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,
);
}
6 changes: 6 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit f030e7e

Please sign in to comment.