Convert settings to svelte

This commit is contained in:
Evan Fiordeliso 2025-06-30 10:14:23 -04:00
parent 42ba4c306f
commit ad0af3d369
25 changed files with 895 additions and 421 deletions

View File

@ -175,7 +175,7 @@ export function createBookFromNextData(
publishedAt: new Date(bookData.details.publicationTime), publishedAt: new Date(bookData.details.publicationTime),
genres: bookData.bookGenres.map((genre) => genre.genre.name), genres: bookData.bookGenres.map((genre) => genre.genre.name),
coverImageUrl: bookData.imageUrl, coverImageUrl: bookData.imageUrl,
pageLength: bookData.details.numPages, pageCount: bookData.details.numPages,
isbn: bookData.details.isbn, isbn: bookData.details.isbn,
isbn13: bookData.details.isbn13, isbn13: bookData.details.isbn13,
}; };

View File

@ -64,7 +64,7 @@ export default class BookTrackerPlugin extends Plugin {
callback: () => this.resetReadingStatus(), callback: () => this.resetReadingStatus(),
}); });
this.addSettingTab(new BookTrackerSettingTab(this.app, this)); this.addSettingTab(new BookTrackerSettingTab(this));
registerReadingLogCodeBlockProcessor(this); registerReadingLogCodeBlockProcessor(this);
} }
@ -94,7 +94,7 @@ export default class BookTrackerPlugin extends Plugin {
throw new Error("Unsupported content type: " + contentType); throw new Error("Unsupported content type: " + contentType);
} }
let filePath = this.settings.coverDirectory + "/"; let filePath = this.settings.coverFolder + "/";
if (this.settings.groupCoversByFirstLetter) { if (this.settings.groupCoversByFirstLetter) {
let groupName = fileName.charAt(0).toUpperCase(); let groupName = fileName.charAt(0).toUpperCase();
if (!/^[A-Z]$/.test(groupName)) { if (!/^[A-Z]$/.test(groupName)) {
@ -150,7 +150,7 @@ export default class BookTrackerPlugin extends Plugin {
if (renderedContent) { if (renderedContent) {
await this.app.vault.create( await this.app.vault.create(
this.settings.tbrDirectory + "/" + fileName + ".md", this.settings.tbrFolder + "/" + fileName + ".md",
renderedContent renderedContent
); );
} }
@ -228,17 +228,17 @@ export default class BookTrackerPlugin extends Plugin {
} }
const fileName = activeFile.basename; const fileName = activeFile.basename;
if (!this.settings.pageLengthProperty) { if (!this.settings.pageCountProperty) {
new Notice("Page length property is not set in settings."); new Notice("Page count property is not set in settings.");
return; return;
} }
const pageLength = const pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[ (this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageLengthProperty this.settings.pageCountProperty
] as number | undefined) ?? 0; ] as number | undefined) ?? 0;
if (pageLength <= 0) { if (pageCount <= 0) {
new Notice( new Notice(
"Page length property is not set or is invalid in the active file." "Page length property is not set or is invalid in the active file."
); );
@ -247,19 +247,19 @@ export default class BookTrackerPlugin extends Plugin {
const pageNumber = await ReadingProgressModal.createAndOpen( const pageNumber = await ReadingProgressModal.createAndOpen(
this.app, this.app,
pageLength pageCount
); );
if (pageNumber <= 0 || pageNumber > pageLength) { if (pageNumber <= 0 || pageNumber > pageCount) {
new Notice( new Notice(
`Invalid page number: ${pageNumber}. It must be between 1 and ${pageLength}.` `Invalid page number: ${pageNumber}. It must be between 1 and ${pageCount}.`
); );
return; return;
} }
await this.readingLog.addEntry(fileName, pageNumber, pageLength); await this.readingLog.addEntry(fileName, pageNumber, pageCount);
new Notice( new Notice(
`Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageLength}.` `Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageCount}.`
); );
} }
@ -290,14 +290,14 @@ export default class BookTrackerPlugin extends Plugin {
return; return;
} }
const pageLength = const pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[ (this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageLengthProperty this.settings.pageCountProperty
] as number | undefined) ?? 0; ] as number | undefined) ?? 0;
if (pageLength <= 0) { if (pageCount <= 0) {
new Notice( new Notice(
"Page length property is not set or is invalid in the active file." "Page count property is not set or is invalid in the active file."
); );
return; return;
} }
@ -306,8 +306,8 @@ export default class BookTrackerPlugin extends Plugin {
await this.readingLog.addEntry( await this.readingLog.addEntry(
activeFile.basename, activeFile.basename,
pageLength, pageCount,
pageLength pageCount
); );
// @ts-expect-error Moment is provided by Obsidian // @ts-expect-error Moment is provided by Obsidian

View File

@ -17,7 +17,7 @@ export interface Book {
publishedAt: Date; publishedAt: Date;
genres: string[]; genres: string[];
coverImageUrl: string; coverImageUrl: string;
pageLength: number; pageCount: number;
isbn: string; isbn: string;
isbn13: string; isbn13: string;
} }

View File

@ -38,10 +38,6 @@
})); }));
}); });
$effect(() => {
console.log(items);
});
function calcSliderPos(e: MouseEvent) { function calcSliderPos(e: MouseEvent) {
return (e.offsetX / (e.target as HTMLElement).clientWidth) * maxV; return (e.offsetX / (e.target as HTMLElement).clientWidth) * maxV;
} }

View File

@ -0,0 +1,28 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
import type { App } from "obsidian";
import FieldSuggest from "../suggesters/FieldSuggest.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
app: App;
id: string;
value?: string;
accepts?: string[];
};
let {
name,
description,
app,
id,
value = $bindable(),
accepts,
}: Props = $props();
</script>
<Item {name} {description}>
{#snippet control()}
<FieldSuggest {id} {app} asString bind:value {accepts} />
{/snippet}
</Item>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
import type { App, TFile } from "obsidian";
import FileSuggest from "../suggesters/FileSuggest.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
app: App;
id: string;
value?: string;
};
let { name, description, app, id, value = $bindable() }: Props = $props();
</script>
<Item {name} {description}>
{#snippet control()}
<FileSuggest {id} {app} asString bind:value />
{/snippet}
</Item>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
import type { App, TFolder } from "obsidian";
import FolderSuggest from "../suggesters/FolderSuggest.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
app: App;
id: string;
value?: string;
};
let { name, description, app, id, value = $bindable() }: Props = $props();
</script>
<Item {name} {description}>
{#snippet control()}
<FolderSuggest {id} {app} asString bind:value />
{/snippet}
</Item>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Props = {
title: string | Snippet;
description?: string | Snippet;
};
let { title, description }: Props = $props();
</script>
<div class="setting-item setting-item-heading">
<p class="title">
{#if typeof title === "string"}
{title}
{:else}
{@render title()}
{/if}
</p>
{#if description}
<p class="description">
{#if typeof description === "string"}
{description}
{:else}
{@render description()}
{/if}
</p>
{/if}
</div>

View File

@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Props = {
name: Snippet | string;
description?: Snippet | string;
control: Snippet;
};
let { name, description, control }: Props = $props();
const descriptionLines = $derived(
typeof description === "string" ? description.split("\n") : [],
);
</script>
<div class="setting-item">
<div class="setting-item-info">
<div class="setting-item-name">
{#if typeof name === "string"}
{name}
{:else}
{@render name()}
{/if}
</div>
{#if description}
<div class="setting-item-description">
{#if typeof description === "string"}
{#each descriptionLines as line}
<div>{line}</div>
{/each}
{:else}
{@render description()}
{/if}
</div>
{/if}
</div>
<div class="setting-item-control">{@render control()}</div>
</div>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
id?: string;
value?: string;
};
let { name, description, id, value = $bindable() }: Props = $props();
</script>
<Item {name} {description}>
{#snippet control()}
<input {id} type="text" bind:value />
{/snippet}
</Item>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
id?: string;
checked?: boolean;
};
let { name, description, id, checked = $bindable() }: Props = $props();
</script>
<Item {name} {description}>
{#snippet control()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- input only covers part of the toggle element. onclick here covers the rest -->
<div
class="checkbox-container"
class:is-enabled={checked}
onclick={() => (checked = !checked)}
>
<input {id} type="checkbox" bind:checked tabindex="0" />
</div>
{/snippet}
</Item>

View File

@ -0,0 +1,60 @@
<script module lang="ts">
export type Field = {
name: string;
type: string;
};
</script>
<script lang="ts">
import type { App } from "obsidian";
import TextInputSuggest, { type Item } from "./TextInputSuggest.svelte";
type Props = {
app: App;
id: string;
asString?: boolean;
value?: Field | string;
accepts?: string[];
onSelected?: (fieldOrName: Field | string) => void;
};
let {
app,
id,
asString,
value = $bindable(),
accepts,
onSelected,
}: Props = $props();
let items: Item<Field | string>[] = $state([]);
async function handleChange(query: string) {
const typesContent = await this.app.vault.adapter.read(
this.app.vault.configDir + "/types.json",
);
const types = JSON.parse(typesContent).types as Record<string, string>;
items = Object.entries(types)
.filter(([name, type]) => {
if (accepts && !accepts.includes(type as string)) {
return false;
}
return name.toLowerCase().includes(query.toLowerCase());
})
.map(([name, type]) => ({
text: name,
value: asString ? name : { name, type },
}));
}
</script>
<TextInputSuggest
{app}
{id}
{items}
bind:value
onChange={handleChange}
{onSelected}
/>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import type { App, TFile } from "obsidian";
import TextInputSuggest, { type Item } from "./TextInputSuggest.svelte";
type Props = {
app: App;
id: string;
asString?: boolean;
value?: TFile | string;
onSelected?: (fileOrPath: TFile | string) => void;
};
let {
app,
id,
asString,
value = $bindable(),
onSelected,
}: Props = $props();
let items: Item<TFile | string>[] = $state([]);
function handleChange(query: string) {
items = app.vault
.getMarkdownFiles()
.filter((f) => f.path.toLowerCase().includes(query.toLowerCase()))
.map((f) => ({ text: f.path, value: asString ? f.path : f }));
}
</script>
<TextInputSuggest
{app}
{id}
{items}
bind:value
onChange={handleChange}
{onSelected}
/>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { TFolder, type App } from "obsidian";
import TextInputSuggest, { type Item } from "./TextInputSuggest.svelte";
type Props = {
app: App;
id: string;
asString?: boolean;
value?: TFolder | string;
onSelected?: (folderOrPath: TFolder | string) => void;
};
let {
app,
id,
asString,
value = $bindable(),
onSelected,
}: Props = $props();
let items: Item<TFolder | string>[] = $state([]);
function handleChange(query: string) {
items = app.vault
.getAllFolders()
.filter((f) => f.path.toLowerCase().includes(query.toLowerCase()))
.map((f) => ({ text: f.path, value: asString ? f.path : f }));
}
</script>
<TextInputSuggest
{app}
{id}
{items}
bind:value
onChange={handleChange}
{onSelected}
/>

View File

@ -0,0 +1,235 @@
<script lang="ts" module>
export type Item<T> = {
text: string;
value: T;
};
</script>
<script lang="ts">
import { clickOutside } from "@ui/directives";
import { App, Scope } from "obsidian";
import { onMount, type Snippet } from "svelte";
import { createPopperActions } from "svelte-popperjs";
type T = $$Generic;
type Props = {
app: App;
id: string;
items: Item<T>[];
value?: T;
loading?: boolean;
suggestion?: Snippet<[Item<T>]>;
onChange?: (text: string) => void | Promise<void>;
onSelected?: (value: T) => void | Promise<void>;
};
let {
app,
id,
items,
value = $bindable(),
loading = false,
suggestion,
onChange,
onSelected,
}: Props = $props();
let query = $state("");
let expanded = $state(false);
let selectedIndex = $state(0);
let listEl: HTMLUListElement | null = $state(null);
onMount(async () => {
await onChange?.(query);
});
$inspect(value, query, items);
$effect.root(() => {
function findIndex(value: T | undefined) {
return items.findIndex((item) => item.value === value);
}
$effect(() => {
const idx = findIndex(value);
if (idx !== -1) {
const item = items[idx];
query = item.text;
selectedIndex = idx;
}
});
});
const [popperRef, popperContent] = createPopperActions({
placement: "bottom-start",
strategy: "fixed",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({ state, instance }) => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
],
});
function wrapAround(value: number, size: number): number {
return ((value % size) + size) % size;
}
function setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
selectedIndex = wrapAround(selectedIndex, items.length);
if (scrollIntoView && listEl) {
listEl.children[selectedIndex].scrollIntoView(false);
}
}
async function selectItem(index: number) {
const item = items[index];
if (!item) return;
const { text: newQuery, value: newValue } = item;
selectedIndex = index;
expanded = false;
query = newQuery;
value = newValue;
await onSelected?.(newValue);
}
async function handleInput() {
expanded = true;
await onChange?.(query);
}
async function clearSearch() {
query = "";
value = undefined;
await onChange?.(query);
}
onMount(() => {
const scope = new Scope();
const arrowUpHandler = scope.register(
[],
"ArrowUp",
(event: KeyboardEvent) => {
if (!event.isComposing) {
setSelectedItem(selectedIndex - 1, true);
return false;
}
},
);
const arrowDownHandler = scope.register(
[],
"ArrowDown",
(event: KeyboardEvent) => {
if (!event.isComposing) {
setSelectedItem(selectedIndex + 1, true);
return false;
}
},
);
const enterHandler = scope.register([], "Enter", (ev) => {
if (!ev.isComposing) {
selectItem(selectedIndex);
return false;
}
});
const escapeHandler = scope.register([], "Escape", (ev) => {
if (!ev.isComposing) {
expanded = false;
return false;
}
});
app.keymap.pushScope(scope);
return () => {
scope.unregister(arrowUpHandler);
scope.unregister(arrowDownHandler);
scope.unregister(enterHandler);
scope.unregister(escapeHandler);
app.keymap.popScope(scope);
};
});
</script>
<div
class:is-loading={loading}
aria-busy={loading}
use:clickOutside={() => (expanded = false)}
>
<div class="search-input-container">
<input
{id}
use:popperRef
type="search"
enterkeyhint="search"
autocomplete="off"
spellcheck="false"
role="combobox"
bind:value={query}
oninput={handleInput}
onfocusin={() => (expanded = true)}
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-expanded={expanded}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="search-input-clear-button"
aria-label="Clear search"
onclick={clearSearch}
onkeydown={(e) => e.key === "Enter" && clearSearch()}
></div>
</div>
{#if expanded}
<div class="suggestion-container" use:popperContent>
<ul
id={`${id}-list`}
bind:this={listEl}
role="listbox"
class="suggestion"
>
{#each items as item, index}
<li
class="suggestion-item"
class:is-selected={index === selectedIndex}
onclick={() => selectItem(index)}
onkeydown={(event) =>
event.key === "Enter" && selectItem(index)}
onmouseover={() => (selectedIndex = index)}
onfocus={() => (selectedIndex = index)}
role="option"
aria-selected={index === selectedIndex}
>
{#if suggestion}
{@render suggestion(item)}
{:else}
{item.text}
{/if}
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@ -0,0 +1,18 @@
export function clickOutside(node: Node, cb: () => void) {
function handleClick(event: MouseEvent) {
if (
node &&
!node.contains(event.target as Node) &&
!event.defaultPrevented
) {
cb();
}
}
document.addEventListener("click", handleClick, true);
return {
update() {},
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}

View File

@ -0,0 +1 @@
export { clickOutside } from "./clickOutside";

View File

@ -7,19 +7,19 @@ export class ReadingProgressModal extends SvelteModal<
> { > {
constructor( constructor(
app: App, app: App,
pageLength: number, pageCount: number,
onSubmit: (pageNumber: number) => void = () => {} onSubmit: (pageNumber: number) => void = () => {}
) { ) {
super(app, ReadingProgressModalView, { super(app, ReadingProgressModalView, {
props: { pageLength, onSubmit }, props: { pageCount, onSubmit },
}); });
} }
static createAndOpen(app: App, pageLength: number): Promise<number> { static createAndOpen(app: App, pageCount: number): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
const modal = new ReadingProgressModal( const modal = new ReadingProgressModal(
app, app,
pageLength, pageCount,
(pageNumber: number) => { (pageNumber: number) => {
modal.close(); modal.close();
resolve(pageNumber); resolve(pageNumber);

View File

@ -1,112 +1,136 @@
<script lang="ts"> <script lang="ts">
import { Notice } from "obsidian"; import { Notice } from "obsidian";
interface Props { interface Props {
pageLength: number; pageCount: number;
onSubmit: (pageNumber: number) => void; onSubmit: (pageNumber: number) => void;
} }
let { pageLength, onSubmit }: Props = $props(); let { pageCount, onSubmit }: Props = $props();
let value = $state(0); let value = $state(0);
let mode: "page-number" | "percentage" = $state("page-number"); let mode: "page-number" | "percentage" = $state("page-number");
const label = $derived(mode === "page-number" ? "Page Number" : "Percentage"); const label = $derived(
const placeholder = $derived( mode === "page-number" ? "Page Number" : "Percentage",
mode === "page-number" ? "Enter page number" : "Enter percentage (0-100)" );
); const placeholder = $derived(
const min = $derived(0); mode === "page-number"
const max = $derived( ? "Enter page number"
mode === "page-number" ? pageLength : 100 : "Enter percentage (0-100)",
); );
const min = $derived(0);
const max = $derived(mode === "page-number" ? pageCount : 100);
function onsubmit(ev: SubmitEvent) { function onsubmit(ev: SubmitEvent) {
ev.preventDefault(); ev.preventDefault();
if (value < min || value > max) { if (value < min || value > max) {
new Notice(`Value must be between ${min} and ${max}.`); new Notice(`Value must be between ${min} and ${max}.`);
return; return;
} }
onSubmit(mode === "page-number" ? value : Math.round((value / 100) * pageLength)); onSubmit(
} mode === "page-number"
? value
: Math.round((value / 100) * pageCount),
);
}
</script> </script>
<div class="obt-reading-progress"> <div class="obt-reading-progress">
<h2>Log Reading Progress</h2> <h2>Log Reading Progress</h2>
<form {onsubmit}> <form {onsubmit}>
<div class="value-field"> <div class="value-field">
<label for="value">{label}</label> <label for="value">{label}</label>
<input id="value" type="number" {placeholder} {min} {max} bind:value /> <input
</div> id="value"
<div class="mode-field"> type="number"
<label class="mode-field-option page-number"> {placeholder}
<span>Page Number</span> {min}
<input name="mode" type="radio" value="page-number" bind:group={mode} /> {max}
</label> bind:value
<label class="mode-field-option percentage"> />
<span>Percentage</span> </div>
<input name="mode" type="radio" value="percentage" bind:group={mode} /> <div class="mode-field">
</label> <label class="mode-field-option page-number">
</div> <span>Page Number</span>
<button type="submit">Submit</button> <input
</form> name="mode"
type="radio"
value="page-number"
bind:group={mode}
/>
</label>
<label class="mode-field-option percentage">
<span>Percentage</span>
<input
name="mode"
type="radio"
value="percentage"
bind:group={mode}
/>
</label>
</div>
<button type="submit">Submit</button>
</form>
</div> </div>
<style lang="scss"> <style lang="scss">
.obt-reading-progress { .obt-reading-progress {
padding-bottom: var(--size-4-4); padding-bottom: var(--size-4-4);
h2 { h2 {
margin-bottom: var(--size-4-6); margin-bottom: var(--size-4-6);
} }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--size-4-4); gap: var(--size-4-4);
.value-field { .value-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: var(--size-4-2); gap: var(--size-4-2);
width: 100%; width: 100%;
} }
.mode-field { .mode-field {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
.mode-field-option { .mode-field-option {
text-align: center; text-align: center;
padding: var(--size-4-2); padding: var(--size-4-2);
background-color: var(--interactive-normal); background-color: var(--interactive-normal);
border: var(--border-width) solid var(--background-modifier-border); border: var(--border-width) solid
border-radius: var(--radius-m); var(--background-modifier-border);
border-radius: var(--radius-m);
&:has(input:checked) { &:has(input:checked) {
background-color: var(--interactive-accent); background-color: var(--interactive-accent);
} }
&:hover { &:hover {
background-color: var(--interactive-hover); background-color: var(--interactive-hover);
} }
input { input {
display: none; display: none;
} }
&.page-number { &.page-number {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
&.percentage { &.percentage {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
} }
} }
} }
} }
</style> </style>

View File

@ -1,303 +1,30 @@
import BookTrackerPlugin from "@src/main"; import BookTrackerPlugin from "@src/main";
import { App, PluginSettingTab, Setting } from "obsidian"; import { App, PluginSettingTab } from "obsidian";
import { FileSuggest, FolderSuggest, FieldSuggest } from "@ui/suggesters"; import BookTrackerSettingTabView from "./BookTrackerSettingTabView.svelte";
import { mount, unmount } from "svelte";
export class BookTrackerSettingTab extends PluginSettingTab { export class BookTrackerSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: BookTrackerPlugin) { private component: ReturnType<typeof BookTrackerSettingTabView> | undefined;
super(app, plugin);
}
heading(text: string): void { constructor(private readonly plugin: BookTrackerPlugin) {
const header = document.createDocumentFragment(); super(plugin.app, plugin);
header.createEl("h2", { text });
new Setting(this.containerEl).setHeading().setName(text);
} }
display(): void { display(): void {
this.containerEl.empty(); this.containerEl.empty();
this.containerEl.classList.add("obt-settings"); this.component = mount(BookTrackerSettingTabView, {
target: this.containerEl,
this.heading("Book Creation Settings"); props: { plugin: this.plugin },
this.templateFileSetting();
this.tbrDirectorySetting();
this.fileNameFormatSetting();
this.heading("Cover Download Settings");
this.downloadCoversSetting();
this.coverDirectorySetting();
this.groupCoversByFirstLetterSetting();
this.overwriteExistingCoversSetting();
this.heading("Reading Progress Settings");
this.statusPropertySetting();
this.startDatePropertySetting();
this.endDatePropertySetting();
this.ratingPropertySetting();
this.pageLengthPropertySetting();
this.readingLogDirectorySetting();
}
readingLogDirectorySetting() {
return new Setting(this.containerEl)
.setName("Reading Log Directory")
.setDesc("Select the directory where reading logs will be stored")
.addSearch((cb) => {
try {
new FolderSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("reading-logs")
.setValue(this.plugin.settings.readingLogDirectory)
.onChange(async (value) => {
this.plugin.settings.readingLogDirectory = value;
await this.plugin.saveSettings();
});
});
}
pageLengthPropertySetting() {
return new Setting(this.containerEl)
.setName("Page Length Property")
.setDesc(
"Property used to track the total number of pages in a book."
)
.addSearch((cb) => {
try {
new FieldSuggest(this.app, cb.inputEl, ["number"]);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("pageLength")
.setValue(this.plugin.settings.pageLengthProperty)
.onChange(async (value) => {
this.plugin.settings.pageLengthProperty = value;
await this.plugin.saveSettings();
});
});
}
ratingPropertySetting() {
return new Setting(this.containerEl)
.setName("Rating Property")
.setDesc("Property used to track the rating of a book.")
.addSearch((cb) => {
try {
new FieldSuggest(this.app, cb.inputEl, ["number"]);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("rating")
.setValue(this.plugin.settings.ratingProperty)
.onChange(async (value) => {
this.plugin.settings.ratingProperty = value;
await this.plugin.saveSettings();
});
});
}
endDatePropertySetting() {
return new Setting(this.containerEl)
.setName("End Date Property")
.setDesc("Property used to track the end date of reading a book.")
.addSearch((cb) => {
try {
new FieldSuggest(this.app, cb.inputEl, ["date"]);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("endDate")
.setValue(this.plugin.settings.endDateProperty)
.onChange(async (value) => {
this.plugin.settings.endDateProperty = value;
await this.plugin.saveSettings();
});
});
}
startDatePropertySetting() {
return new Setting(this.containerEl)
.setName("Start Date Property")
.setDesc("Property used to track the start date of reading a book.")
.addSearch((cb) => {
try {
new FieldSuggest(this.app, cb.inputEl, ["date"]);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("startDate")
.setValue(this.plugin.settings.startDateProperty)
.onChange(async (value) => {
this.plugin.settings.startDateProperty = value;
await this.plugin.saveSettings();
});
});
}
statusPropertySetting() {
return new Setting(this.containerEl)
.setName("Status Property")
.setDesc("Property used to track the reading status of a book.")
.addSearch((cb) => {
try {
new FieldSuggest(this.app, cb.inputEl, ["text"]);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("status")
.setValue(this.plugin.settings.statusProperty)
.onChange(async (value) => {
this.plugin.settings.statusProperty = value;
await this.plugin.saveSettings();
});
});
}
overwriteExistingCoversSetting() {
return new Setting(this.containerEl)
.setName("Overwrite Existing Covers")
.setDesc("Overwrite existing book covers when downloading new ones")
.addToggle((cb) => {
cb.setValue(
this.plugin.settings.overwriteExistingCovers
).onChange(async (value) => {
this.plugin.settings.overwriteExistingCovers = value;
await this.plugin.saveSettings();
});
});
}
groupCoversByFirstLetterSetting() {
return new Setting(this.containerEl)
.setName("Group Covers by First Letter")
.setDesc(
"Organize downloaded book covers into subdirectories based on the first letter of the book title"
)
.addToggle((cb) => {
cb.setValue(
this.plugin.settings.groupCoversByFirstLetter
).onChange(async (value) => {
this.plugin.settings.groupCoversByFirstLetter = value;
await this.plugin.saveSettings();
});
});
}
coverDirectorySetting() {
return new Setting(this.containerEl)
.setName("Cover Directory")
.setDesc(
"Select the directory where downloaded book covers will be stored"
)
.addSearch((cb) => {
try {
new FolderSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("images/covers")
.setValue(this.plugin.settings.coverDirectory)
.onChange(async (value) => {
this.plugin.settings.coverDirectory = value;
await this.plugin.saveSettings();
});
});
}
downloadCoversSetting() {
return new Setting(this.containerEl)
.setName("Download Covers")
.setDesc(
"Automatically download book covers when creating new entries"
)
.addToggle((cb) => {
cb.setValue(this.plugin.settings.downloadCovers).onChange(
async (value) => {
this.plugin.settings.downloadCovers = value;
await this.plugin.saveSettings();
}
);
});
}
fileNameFormatSetting() {
const fileNameFormatDesc = document.createDocumentFragment();
fileNameFormatDesc.createDiv({
text: "Format for the file name of new book entries.",
}); });
fileNameFormatDesc.createDiv({
text: "Use {{title}} and {{authors}} as placeholders.",
});
new Setting(this.containerEl)
.setName("File Name Format")
.setDesc(fileNameFormatDesc)
.addText((cb) => {
cb.setPlaceholder("{{title}} - {{authors}}")
.setValue(this.plugin.settings.fileNameFormat)
.onChange(async (value) => {
this.plugin.settings.fileNameFormat = value;
await this.plugin.saveSettings();
});
});
} }
tbrDirectorySetting() { hide(): void {
return new Setting(this.containerEl) super.hide();
.setName("To Be Read Directory")
.setDesc(
"Select the directory where new book entries will be created"
)
.addSearch((cb) => {
try {
new FolderSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
const { containerEl } = this;
cb.setPlaceholder("books/tbr") if (this.component) {
.setValue(this.plugin.settings.tbrDirectory) unmount(this.component);
.onChange(async (value) => { this.component = undefined;
this.plugin.settings.tbrDirectory = value; }
await this.plugin.saveSettings(); this.containerEl.empty();
});
});
}
templateFileSetting() {
return new Setting(this.containerEl)
.setName("Template File")
.setDesc("Select the template file to use for new book entries")
.addSearch((cb) => {
try {
new FileSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("templates/book-template")
.setValue(this.plugin.settings.templateFile)
.onChange(async (value) => {
this.plugin.settings.templateFile = value;
await this.plugin.saveSettings();
});
});
} }
} }

View File

@ -0,0 +1,118 @@
<script lang="ts">
import Header from "@ui/components/setting/Header.svelte";
import FileSuggestItem from "@ui/components/setting/FileSuggestItem.svelte";
import FolderSuggestItem from "@ui/components/setting/FolderSuggestItem.svelte";
import TextInputItem from "@ui/components/setting/TextInputItem.svelte";
import FieldSuggestItem from "@ui/components/setting/FieldSuggestItem.svelte";
import ToggleItem from "@ui/components/setting/ToggleItem.svelte";
import type BookTrackerPlugin from "@src/main";
import { createSettingsStore } from "./store";
import { onMount } from "svelte";
type Props = {
plugin: BookTrackerPlugin;
};
const { plugin }: Props = $props();
const { app } = plugin;
const settings = createSettingsStore(plugin);
onMount(async () => {
await settings.load();
});
</script>
<div class="obt-settings">
<Header title="Book Creation Settings" />
<FileSuggestItem
{app}
id="template-file"
name="Template File"
description="Select the template file to use for new book entries"
bind:value={$settings.templateFile}
/>
<FolderSuggestItem
{app}
id="tbr-folder"
name="To Be Read Folder"
description="Select the folder to use for To Be Read entries"
bind:value={$settings.tbrFolder}
/>
<TextInputItem
id="file-name-format"
name="File name Format"
description={`Format for the file name of new book entries.
Use {{title}} and {{authors}} as placeholders.`}
bind:value={$settings.fileNameFormat}
/>
<Header title="Cover Download Settings" />
<ToggleItem
id="download-covers"
name="Download Covers"
description="Automatically download book covers when creating new entries"
bind:checked={$settings.downloadCovers}
/>
<FolderSuggestItem
{app}
id="cover-folder"
name="Cover Folder"
description="Select the folder to download covers to"
bind:value={$settings.coverFolder}
/>
<ToggleItem
id="group-covers"
name="Group Covers by First Letter"
description="Organize downloaded book covers into folders based on the first letter of the book title"
bind:checked={$settings.groupCoversByFirstLetter}
/>
<ToggleItem
id="overwrite-covers"
name="Overwrite Existing Covers"
description="Overwrite existing covers when downloading new ones"
bind:checked={$settings.overwriteExistingCovers}
/>
<Header title="Reading Progress Settings" />
<FieldSuggestItem
{app}
id="status-field"
name="Status Field"
description="Select the folder to use for To Be Read entries"
bind:value={$settings.statusProperty}
accepts={["text"]}
/>
<FieldSuggestItem
{app}
id="start-date-field"
name="Start Date Field"
description="Select the field to use for start date"
bind:value={$settings.startDateProperty}
accepts={["date"]}
/>
<FieldSuggestItem
{app}
id="end-date-field"
name="End Date Field"
description="Select the field to use for end date"
bind:value={$settings.endDateProperty}
accepts={["date"]}
/>
<FieldSuggestItem
{app}
id="rating-field"
name="Rating Field"
description="Select the field to use for rating"
bind:value={$settings.ratingProperty}
accepts={["number"]}
/>
<FieldSuggestItem
{app}
id="page-count-field"
name="Page Count Field"
description="Select the field to use for page count"
bind:value={$settings.pageCountProperty}
accepts={["number"]}
/>
</div>

View File

@ -1,2 +1,5 @@
export { BookTrackerSettingTab } from "./BookTrackerSettingTab"; export { BookTrackerSettingTab } from "./BookTrackerSettingTab";
export { type BookTrackerPluginSettings, DEFAULT_SETTINGS } from "./types"; export {
type BookTrackerSettings as BookTrackerPluginSettings,
DEFAULT_SETTINGS,
} from "./types";

39
src/ui/settings/store.ts Normal file
View File

@ -0,0 +1,39 @@
import type BookTrackerPlugin from "@src/main";
import { writable, type Writable } from "svelte/store";
import { type BookTrackerSettings, DEFAULT_SETTINGS } from "./types";
type SettingsStore = Writable<BookTrackerSettings> & {
load: () => Promise<void>;
};
export function createSettingsStore(plugin: BookTrackerPlugin): SettingsStore {
const { subscribe, set, update } =
writable<BookTrackerSettings>(DEFAULT_SETTINGS);
async function load() {
const settings = await plugin.loadData();
update((currentSettings) => {
return {
...currentSettings,
...settings,
};
});
}
subscribe((settings) => {
if (settings === DEFAULT_SETTINGS) {
return;
}
plugin.settings = settings;
plugin.saveSettings();
});
return {
subscribe,
set,
update,
load,
};
}

View File

@ -1,31 +1,29 @@
export interface BookTrackerPluginSettings { export interface BookTrackerSettings {
templateFile: string; templateFile: string;
tbrDirectory: string; tbrFolder: string;
fileNameFormat: string; fileNameFormat: string;
downloadCovers: boolean; downloadCovers: boolean;
coverDirectory: string; coverFolder: string;
groupCoversByFirstLetter: boolean; groupCoversByFirstLetter: boolean;
overwriteExistingCovers: boolean; overwriteExistingCovers: boolean;
statusProperty: string; statusProperty: string;
startDateProperty: string; startDateProperty: string;
endDateProperty: string; endDateProperty: string;
ratingProperty: string; ratingProperty: string;
pageLengthProperty: string; pageCountProperty: string;
readingLogDirectory: string;
} }
export const DEFAULT_SETTINGS: BookTrackerPluginSettings = { export const DEFAULT_SETTINGS: BookTrackerSettings = {
templateFile: "", templateFile: "",
tbrDirectory: "books/tbr", tbrFolder: "books/tbr",
fileNameFormat: "{{title}} - {{authors}}", fileNameFormat: "{{title}} - {{authors}}",
downloadCovers: false, downloadCovers: false,
coverDirectory: "images/covers", coverFolder: "images/covers",
groupCoversByFirstLetter: true, groupCoversByFirstLetter: true,
overwriteExistingCovers: false, overwriteExistingCovers: false,
statusProperty: "status", statusProperty: "status",
startDateProperty: "startDate", startDateProperty: "startDate",
endDateProperty: "endDate", endDateProperty: "endDate",
ratingProperty: "rating", ratingProperty: "rating",
pageLengthProperty: "pageLength", pageCountProperty: "pageCount",
readingLogDirectory: "reading-logs",
}; };

View File

@ -57,7 +57,7 @@ export class ReadingLog {
private sortEntries() { private sortEntries() {
this.entries = this.entries.sort( this.entries = this.entries.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime() (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
); );
} }
@ -83,7 +83,7 @@ export class ReadingLog {
public async addEntry( public async addEntry(
book: string, book: string,
pageEnded: number, pageEnded: number,
pageLength: number pageCount: number
): Promise<void> { ): Promise<void> {
const latestEntry = this.getLatestEntry(book); const latestEntry = this.getLatestEntry(book);
@ -93,7 +93,7 @@ export class ReadingLog {
? pageEnded - latestEntry.pagesReadTotal ? pageEnded - latestEntry.pagesReadTotal
: pageEnded, : pageEnded,
pagesReadTotal: pageEnded, pagesReadTotal: pageEnded,
pagesRemaining: pageLength - pageEnded, pagesRemaining: pageCount - pageEnded,
createdAt: new Date(), createdAt: new Date(),
}; };