Skip to content

Commit

Permalink
Merge pull request #342 from danielo515/feat/file-input
Browse files Browse the repository at this point in the history
Feat/file-input
  • Loading branch information
danielo515 authored Dec 11, 2024
2 parents 589b789 + afad3d4 commit cf2838c
Show file tree
Hide file tree
Showing 16 changed files with 380 additions and 15 deletions.
20 changes: 20 additions & 0 deletions EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,26 @@
}
],
"version": "1"
},
{
"title": "File input example",
"name": "file_input",
"fields": [
{
"name": "file",
"label": "",
"description": "Pick a file to attach",
"isRequired": false,
"input": {
"type": "file",
"folder": "attachments/pdfs",
"allowedExtensions": [
"pdf"
]
}
}
],
"version": "1"
}
]
}
25 changes: 25 additions & 0 deletions EXAMPLE_VAULT/000 - Templates/file input example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<%*
const modalForm = app.plugins.plugins.modalforms.api;
const result = await modalForm.openForm('file_input');
tR += result.asFrontmatterString();
console.log(result.file)
-%>

Here is the file manually rendered
![[<% result.file.value.name %>]]

And here is the file leveraging the shorthand to get a markdown link
<% result.file.link %>

Some TFile proxy properties:

- path: <% result.file.value.path %>
- name: <% result.file.value.name %>
- basename: <% result.file.value.basename %>
- extension: <% result.file.value.extension %>

Some TFile properties:

- ctime: <% result.file.value.TFile.stat.ctime %>
- mtime: <% result.file.value.TFile.stat.mtime %>
- size: <% result.file.value.TFile.stat.size %>
32 changes: 25 additions & 7 deletions src/core/ResultValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function deepMap(value: unknown, fn: (value: unknown) => unknown): unknown {
function deepMap(value: unknown, fn: (value: unknown) => unknown, iterations = 0): unknown {
if (iterations > 10) {
return fn(value);
}
if (Array.isArray(value)) {
return value.map((v) => deepMap(v, fn));
return value.map((v) => deepMap(v, fn, iterations + 1));
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => [key, deepMap(value, fn)]),
Object.entries(value).map(([key, value]) => [key, deepMap(value, fn, iterations + 1)]),
);
}
return fn(value);
Expand Down Expand Up @@ -84,7 +87,11 @@ export class ResultValue<T = unknown> {
return `- ${this.value}`;
case "object": {
const value = this.value;
if (value === null) return "";
// if the value is null or undefined, return an empty string
if (value == null) return "";
if (value instanceof FileProxy) {
return `- ${value.name}`;
}
if (Array.isArray(value)) {
return _toBulletList(value);
}
Expand Down Expand Up @@ -159,8 +166,12 @@ export class ResultValue<T = unknown> {
/**
* getter that returns all the string values uppercased.
* If the value is an array, it will return an array with all the strings uppercased.
* The usage of map is important for safety and method chaining.
*/
get upper() {
get upper(): ResultValue<unknown> {
if (this.value instanceof FileProxy) {
return new ResultValue(this.value.name.toLocaleUpperCase(), this.name, this.notify);
}
return this.map((v) =>
deepMap(v, (it) => (typeof it === "string" ? it.toLocaleUpperCase() : it)),
);
Expand All @@ -169,17 +180,24 @@ export class ResultValue<T = unknown> {
* getter that returns all the string values lowercased.
* If the value is an array, it will return an array with all the strings lowercased.
* If the value is an object, it will return an object with all the string values lowercased.
* The usage of map is important for safety and method chaining.
* @returns FormValue
*/
get lower() {
get lower(): ResultValue<unknown> {
if (this.value instanceof FileProxy) {
return new ResultValue(this.value.name.toLocaleLowerCase(), this.name, this.notify);
}
return this.map((v) =>
deepMap(v, (it) => (typeof it === "string" ? it.toLocaleLowerCase() : it)),
);
}
/**
* getter that returns all the string values trimmed.
* */
get trimmed() {
get trimmed(): ResultValue<unknown> {
if (this.value instanceof FileProxy) {
return new ResultValue(this.value.name.trim(), this.name, this.notify);
}
return this.map((v) => deepMap(v, (it) => (typeof it === "string" ? it.trim() : it)));
}

Expand Down
4 changes: 4 additions & 0 deletions src/core/files/FileProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,8 @@ export class FileProxy<T extends FileProps = FileProps> {
extension: this.extension,
};
}

toString(): string {
return this.path;
}
}
2 changes: 1 addition & 1 deletion src/core/files/FileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type FileProps = {
};

export class FileError extends Error {
protected readonly _tag = "FileError";
readonly _tag = "FileError";
constructor(
message: string,
readonly cause: unknown,
Expand Down
9 changes: 7 additions & 2 deletions src/core/files/FileServiceObsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export class ObsidianFileService implements FileService {
createFile = (fullPath: string, content: ArrayBuffer) =>
TE.tryCatch(
() => this.app.vault.createBinary(fullPath, content),
FileError.of("Error saving file"),
(err) =>
err instanceof Error
? new FileError(err.message, err)
: new FileError("Error creating file", err),
);
createFolder = (fullPath: string) =>
TE.tryCatch(
Expand All @@ -27,7 +30,9 @@ export class ObsidianFileService implements FileService {
this.logger.debug("Folder does not exist, creating it", err);
return this.createFolder(path);
}),
TE.mapLeft(FileError.of("Error saving file")),
TE.catchTag("NotAFolderError", (err) =>
TE.left(new FileError("Destination is not a folder", err)),
),
TE.map((tFolder) => normalizePath(`${tFolder.path}/${fileName}`)),
TE.chain((path) => this.createFile(path, content)),
);
Expand Down
1 change: 1 addition & 0 deletions src/core/formDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const InputTypeReadable: Record<AllFieldTypes, string> = {
document_block: "Document block",
markdown_block: "Markdown block",
image: "Image",
file: "File",
} as const;

export function isDataViewSource(input: unknown): input is inputDataviewSource {
Expand Down
11 changes: 11 additions & 0 deletions src/core/input/InputDefinitionSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ export const ImageInputSchema = object({
saveLocation: nonEmptyString("save location"),
});

export const FileInputSchema = object({
type: literal("file"),
folder: nonEmptyString("folder"),
allowedExtensions: optional(array(string())),
});

// Codec for all the input types
export const InputTypeSchema = union([
InputBasicSchema,
Expand All @@ -160,10 +166,13 @@ export const InputTypeSchema = union([
DocumentBlock,
MarkdownBlock,
ImageInputSchema,
FileInputSchema,
]);

export type Input = Output<typeof InputTypeSchema>;

export type fileInput = Output<typeof FileInputSchema>;

export const InputTypeToParserMap: Record<AllFieldTypes, ParsingFn<BaseSchema>> = {
number: parseC(InputBasicSchema),
text: parseC(InputBasicSchema),
Expand All @@ -184,6 +193,7 @@ export const InputTypeToParserMap: Record<AllFieldTypes, ParsingFn<BaseSchema>>
document_block: parseC(DocumentBlock),
markdown_block: parseC(MarkdownBlock),
image: parseC(ImageInputSchema),
file: parseC(FileInputSchema),
};

//=========== Types derived from schemas
Expand Down Expand Up @@ -226,6 +236,7 @@ export function requiresListOfStrings(input: inputType): boolean {
case "email":
case "tel":
case "image":
case "file":
return false;
default:
return absurd(type);
Expand Down
1 change: 1 addition & 0 deletions src/core/input/dependentFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function availableConditionsForInput(input: FieldDefinition["input"]): Co
case "markdown_block":
return [];
case "image":
case "file":
return ["isSet"];
default:
return absurd(input);
Expand Down
16 changes: 13 additions & 3 deletions src/exampleModalDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,22 @@ export const exampleModalDefinition: FormDefinition = {
{
name: "profile_picture",
label: "Profile Picture",
description: "Upload a profile picture. It will be saved in the attachments folder with the current date.",
description:
"Upload a profile picture. It will be saved in the attachments folder with the current date.",
input: {
type: "image",
saveLocation: "attachments/profile_pictures",
filenameTemplate: "profile-{{datetime}}.png"
}
filenameTemplate: "profile-{{datetime}}.png",
},
},
{
name: "pdf",
description: "PDF example",
input: {
type: "file",
folder: "attachments/pdfs",
allowedExtensions: ["pdf"],
},
},
{
name: "document",
Expand Down
11 changes: 10 additions & 1 deletion src/views/FormBuilder.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import InputBuilderDocumentBlock from "./components/InputBuilderDocumentBlock.svelte";
import InputFolder from "./components/InputBuilderFolder.svelte";
import InputBuilderImage from "./components/InputBuilderImage.svelte";
import InputBuilderFile from "./components/InputBuilderFile.svelte";
import InputBuilderSelect from "./components/InputBuilderSelect.svelte";
import Tabs from "./components/Tabs.svelte";
import TemplateEditor from "./components/TemplateEditor.svelte";
Expand Down Expand Up @@ -370,10 +371,18 @@
notifyChange={onChange}
{app}
/>
{:else if field.input.type === "file"}
<InputBuilderFile
{index}
bind:folder={field.input.folder}
bind:allowedExtensions={field.input.allowedExtensions}
notifyChange={onChange}
{app}
/>
{/if}
</div>

{#if ["text", "email", "tel", "number", "note", "tag", "dataview", "multiselect"].includes(field.input.type)}
{#if ["text", "email", "tel", "number", "note", "tag", "dataview", "multiselect", "file"].includes(field.input.type)}
<FormRow label="Required" id={`required_${index}`}>
<Toggle bind:checked={field.isRequired} tabindex={index} />
</FormRow>
Expand Down
79 changes: 79 additions & 0 deletions src/views/components/Form/FileInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script lang="ts">
import { E } from "@std";
import { FileProxy } from "src/core/files/FileProxy";
import { FileInputModel } from "./FileInputModel";
export let id: string;
export let model: FileInputModel;
export let value: FileProxy | null = null;
let input: HTMLInputElement;
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files?.[0]) {
model.handleFileChange(files[0]);
}
}
$: ({ error, result } = model);
$: if (E.isRight($result)) {
value = $result.right ?? null;
}
</script>

<div class="file-input">
{#if value}
<div class="file-preview">
<span>{value.path}</span>
</div>
{:else}
<input
bind:this={input}
{id}
type="file"
accept={model.accepted}
on:change={handleFileChange}
/>
{/if}
{#if $error}
<div class="error">{$error}</div>
{/if}
</div>

<style>
.file-input {
width: 100%;
}
.file-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--background-modifier-form-field);
border-radius: var(--radius-s);
}
.clear-button {
padding: 0 0.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
color: var(--text-muted);
}
.clear-button:hover {
color: var(--text-normal);
}
input[type="file"] {
width: 100%;
padding: 0.5rem;
background-color: var(--background-modifier-form-field);
border-radius: var(--radius-s);
}
.error {
color: var(--text-error);
}
</style>
Loading

0 comments on commit cf2838c

Please sign in to comment.