generated from tpl/obsidian-sample-plugin
Convert settings to svelte
This commit is contained in:
parent
42ba4c306f
commit
ad0af3d369
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
38
src/main.ts
38
src/main.ts
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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>
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { clickOutside } from "./clickOutside";
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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";
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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",
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue