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),
genres: bookData.bookGenres.map((genre) => genre.genre.name),
coverImageUrl: bookData.imageUrl,
pageLength: bookData.details.numPages,
pageCount: bookData.details.numPages,
isbn: bookData.details.isbn,
isbn13: bookData.details.isbn13,
};

View File

@ -64,7 +64,7 @@ export default class BookTrackerPlugin extends Plugin {
callback: () => this.resetReadingStatus(),
});
this.addSettingTab(new BookTrackerSettingTab(this.app, this));
this.addSettingTab(new BookTrackerSettingTab(this));
registerReadingLogCodeBlockProcessor(this);
}
@ -94,7 +94,7 @@ export default class BookTrackerPlugin extends Plugin {
throw new Error("Unsupported content type: " + contentType);
}
let filePath = this.settings.coverDirectory + "/";
let filePath = this.settings.coverFolder + "/";
if (this.settings.groupCoversByFirstLetter) {
let groupName = fileName.charAt(0).toUpperCase();
if (!/^[A-Z]$/.test(groupName)) {
@ -150,7 +150,7 @@ export default class BookTrackerPlugin extends Plugin {
if (renderedContent) {
await this.app.vault.create(
this.settings.tbrDirectory + "/" + fileName + ".md",
this.settings.tbrFolder + "/" + fileName + ".md",
renderedContent
);
}
@ -228,17 +228,17 @@ export default class BookTrackerPlugin extends Plugin {
}
const fileName = activeFile.basename;
if (!this.settings.pageLengthProperty) {
new Notice("Page length property is not set in settings.");
if (!this.settings.pageCountProperty) {
new Notice("Page count property is not set in settings.");
return;
}
const pageLength =
const pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageLengthProperty
this.settings.pageCountProperty
] as number | undefined) ?? 0;
if (pageLength <= 0) {
if (pageCount <= 0) {
new Notice(
"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(
this.app,
pageLength
pageCount
);
if (pageNumber <= 0 || pageNumber > pageLength) {
if (pageNumber <= 0 || pageNumber > pageCount) {
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;
}
await this.readingLog.addEntry(fileName, pageNumber, pageLength);
await this.readingLog.addEntry(fileName, pageNumber, pageCount);
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;
}
const pageLength =
const pageCount =
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
this.settings.pageLengthProperty
this.settings.pageCountProperty
] as number | undefined) ?? 0;
if (pageLength <= 0) {
if (pageCount <= 0) {
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;
}
@ -306,8 +306,8 @@ export default class BookTrackerPlugin extends Plugin {
await this.readingLog.addEntry(
activeFile.basename,
pageLength,
pageLength
pageCount,
pageCount
);
// @ts-expect-error Moment is provided by Obsidian

View File

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

View File

@ -38,10 +38,6 @@
}));
});
$effect(() => {
console.log(items);
});
function calcSliderPos(e: MouseEvent) {
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(
app: App,
pageLength: number,
pageCount: number,
onSubmit: (pageNumber: number) => void = () => {}
) {
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) => {
const modal = new ReadingProgressModal(
app,
pageLength,
pageCount,
(pageNumber: number) => {
modal.close();
resolve(pageNumber);

View File

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

View File

@ -1,303 +1,30 @@
import BookTrackerPlugin from "@src/main";
import { App, PluginSettingTab, Setting } from "obsidian";
import { FileSuggest, FolderSuggest, FieldSuggest } from "@ui/suggesters";
import { App, PluginSettingTab } from "obsidian";
import BookTrackerSettingTabView from "./BookTrackerSettingTabView.svelte";
import { mount, unmount } from "svelte";
export class BookTrackerSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: BookTrackerPlugin) {
super(app, plugin);
}
private component: ReturnType<typeof BookTrackerSettingTabView> | undefined;
heading(text: string): void {
const header = document.createDocumentFragment();
header.createEl("h2", { text });
new Setting(this.containerEl).setHeading().setName(text);
constructor(private readonly plugin: BookTrackerPlugin) {
super(plugin.app, plugin);
}
display(): void {
this.containerEl.empty();
this.containerEl.classList.add("obt-settings");
this.heading("Book Creation Settings");
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.",
this.component = mount(BookTrackerSettingTabView, {
target: this.containerEl,
props: { plugin: this.plugin },
});
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() {
return new Setting(this.containerEl)
.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;
hide(): void {
super.hide();
cb.setPlaceholder("books/tbr")
.setValue(this.plugin.settings.tbrDirectory)
.onChange(async (value) => {
this.plugin.settings.tbrDirectory = value;
await this.plugin.saveSettings();
});
});
}
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();
});
});
if (this.component) {
unmount(this.component);
this.component = undefined;
}
this.containerEl.empty();
}
}

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 { 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;
tbrDirectory: string;
tbrFolder: string;
fileNameFormat: string;
downloadCovers: boolean;
coverDirectory: string;
coverFolder: string;
groupCoversByFirstLetter: boolean;
overwriteExistingCovers: boolean;
statusProperty: string;
startDateProperty: string;
endDateProperty: string;
ratingProperty: string;
pageLengthProperty: string;
readingLogDirectory: string;
pageCountProperty: string;
}
export const DEFAULT_SETTINGS: BookTrackerPluginSettings = {
export const DEFAULT_SETTINGS: BookTrackerSettings = {
templateFile: "",
tbrDirectory: "books/tbr",
tbrFolder: "books/tbr",
fileNameFormat: "{{title}} - {{authors}}",
downloadCovers: false,
coverDirectory: "images/covers",
coverFolder: "images/covers",
groupCoversByFirstLetter: true,
overwriteExistingCovers: false,
statusProperty: "status",
startDateProperty: "startDate",
endDateProperty: "endDate",
ratingProperty: "rating",
pageLengthProperty: "pageLength",
readingLogDirectory: "reading-logs",
pageCountProperty: "pageCount",
};

View File

@ -57,7 +57,7 @@ export class ReadingLog {
private sortEntries() {
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(
book: string,
pageEnded: number,
pageLength: number
pageCount: number
): Promise<void> {
const latestEntry = this.getLatestEntry(book);
@ -93,7 +93,7 @@ export class ReadingLog {
? pageEnded - latestEntry.pagesReadTotal
: pageEnded,
pagesReadTotal: pageEnded,
pagesRemaining: pageLength - pageEnded,
pagesRemaining: pageCount - pageEnded,
createdAt: new Date(),
};