Extract book properties into object

This commit is contained in:
Evan Fiordeliso 2025-07-15 11:56:43 -04:00
parent 19d56652eb
commit 8356b6649f
35 changed files with 554 additions and 464 deletions

View File

@ -1,5 +1,6 @@
import type { ReadingLog } from "@utils/ReadingLog";
import { Command } from "./Command";
import moment from "@external/moment";
export class BackupReadingLogCommand extends Command {
constructor(private readonly readingLog: ReadingLog) {
@ -7,7 +8,6 @@ export class BackupReadingLogCommand extends Command {
}
async callback() {
// @ts-expect-error Moment is provided by Obsidian
const timestamp = moment().format("YYYY-MM-DD_HH-mm-ss");
const backupFilename = `reading-log-backup_${timestamp}.json`;
await this.readingLog.save(backupFilename);

View File

@ -2,32 +2,23 @@ import {
type Editor,
type MarkdownView,
type MarkdownFileInfo,
type App,
Notice,
TFile,
} from "obsidian";
import { EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { RatingModal } from "@ui/modals";
import type { ReadingLog } from "@utils/ReadingLog";
import { STATUS_READ } from "@src/const";
import { mkdirRecursive, dirname } from "@utils/fs";
import moment from "@external/moment";
import type BookTrackerPlugin from "@src/main";
export class LogReadingFinishedCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly readingLog: ReadingLog,
private readonly settings: BookTrackerPluginSettings
) {
constructor(private readonly plugin: BookTrackerPlugin) {
super("log-reading-finished", "Log Reading Finished");
}
private getPageCount(file: TFile): number {
return (
(this.app.metadataCache.getFileCache(file)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0
);
return this.plugin.getBookMetadata(file)?.pageCount ?? 0;
}
protected check(
@ -36,10 +27,10 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
): boolean {
return !(
ctx.file === null ||
this.settings.statusProperty === "" ||
this.settings.endDateProperty === "" ||
this.settings.ratingProperty === "" ||
this.settings.pageCountProperty === "" ||
this.plugin.settings.statusProperty === "" ||
this.plugin.settings.endDateProperty === "" ||
this.plugin.settings.ratingProperty === "" ||
this.plugin.settings.pageCountProperty === "" ||
this.getPageCount(ctx.file) <= 0
);
}
@ -53,12 +44,16 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
const pageCount = this.getPageCount(file);
const ratings = await RatingModal.createAndOpen(
this.app,
this.settings.spiceProperty !== ""
this.plugin.app,
this.plugin.settings.spiceProperty !== ""
);
try {
await this.readingLog.createEntry(fileName, pageCount, pageCount);
await this.plugin.readingLog.createEntry(
fileName,
pageCount,
pageCount
);
} catch (error) {
new Notice(
`Failed to log reading progress for ${fileName}: ${error}`
@ -66,25 +61,23 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
return;
}
// @ts-expect-error Moment is provided by Obsidian
const endDate = moment().format("YYYY-MM-DD");
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = STATUS_READ;
frontMatter[this.settings.endDateProperty] = endDate;
frontMatter[this.settings.ratingProperty] = ratings.rating;
if (this.settings.spiceProperty !== "") {
frontMatter[this.settings.spiceProperty] = ratings.spice;
this.plugin.app.fileManager.processFrontMatter(file, (fm) => {
fm[this.plugin.settings.statusProperty] = STATUS_READ;
fm[this.plugin.settings.endDateProperty] = endDate;
fm[this.plugin.settings.ratingProperty] = ratings.rating;
if (this.plugin.settings.spiceProperty !== "") {
fm[this.plugin.settings.spiceProperty] = ratings.spice;
}
});
if (this.settings.organizeReadBooks) {
// @ts-expect-error Moment is provided by Obsidian
if (this.plugin.settings.organizeReadBooks) {
const datePath = moment().format("YYYY/MMMM");
const newPath = `${this.settings.readBooksFolder}/${datePath}/${file.name}`;
const newPath = `${this.plugin.settings.readBooksFolder}/${datePath}/${file.name}`;
await mkdirRecursive(this.app.vault, dirname(newPath));
await this.app.vault.rename(file, newPath);
await mkdirRecursive(this.plugin.app.vault, dirname(newPath));
await this.plugin.app.vault.rename(file, newPath);
}
new Notice("Reading finished for " + fileName);

View File

@ -2,30 +2,20 @@ import {
type Editor,
type MarkdownView,
type MarkdownFileInfo,
type App,
Notice,
TFile,
} from "obsidian";
import { EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { ReadingProgressModal } from "@ui/modals";
import type { ReadingLog } from "@utils/ReadingLog";
import type BookTrackerPlugin from "@src/main";
export class LogReadingProgressCommand extends EditorCheckCommand {
constructor(
private readonly app: App,
private readonly readingLog: ReadingLog,
private readonly settings: BookTrackerPluginSettings
) {
constructor(private readonly plugin: BookTrackerPlugin) {
super("log-reading-progress", "Log Reading Progress");
}
private getPageCount(file: TFile): number {
return (
(this.app.metadataCache.getFileCache(file)?.frontmatter?.[
this.settings.pageCountProperty
] as number | undefined) ?? 0
);
return this.plugin.getBookMetadata(file)?.pageCount ?? 0;
}
protected check(
@ -34,7 +24,7 @@ export class LogReadingProgressCommand extends EditorCheckCommand {
): boolean {
return !(
ctx.file === null ||
this.settings.pageCountProperty === "" ||
this.plugin.settings.pageCountProperty === "" ||
this.getPageCount(ctx.file) <= 0
);
}
@ -47,7 +37,7 @@ export class LogReadingProgressCommand extends EditorCheckCommand {
const fileName = file.basename;
const pageCount = this.getPageCount(file);
const pageNumber = await ReadingProgressModal.createAndOpen(
this.app,
this.plugin.app,
pageCount
);
@ -59,7 +49,11 @@ export class LogReadingProgressCommand extends EditorCheckCommand {
}
try {
await this.readingLog.createEntry(fileName, pageNumber, pageCount);
await this.plugin.readingLog.createEntry(
fileName,
pageNumber,
pageCount
);
} catch (error) {
new Notice(
`Failed to log reading progress for ${fileName}: ${error}`

View File

@ -8,6 +8,7 @@ import {
import { EditorCheckCommand } from "./Command";
import type { BookTrackerPluginSettings } from "@ui/settings";
import { STATUS_IN_PROGRESS } from "@src/const";
import moment from "@external/moment";
export class LogReadingStartedCommand extends EditorCheckCommand {
constructor(
@ -34,12 +35,11 @@ export class LogReadingStartedCommand extends EditorCheckCommand {
): Promise<void> {
const file = ctx.file!;
// @ts-expect-error Moment is provided by Obsidian
const startDate = moment().format("YYYY-MM-DD");
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = STATUS_IN_PROGRESS;
frontMatter[this.settings.startDateProperty] = startDate;
this.app.fileManager.processFrontMatter(file, (fm) => {
fm[this.settings.statusProperty] = STATUS_IN_PROGRESS;
fm[this.settings.startDateProperty] = startDate;
});
new Notice("Reading started for " + file.basename);

View File

@ -37,10 +37,10 @@ export class ResetReadingStatusCommand extends EditorCheckCommand {
): void | Promise<void> {
const file = ctx.file!;
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = STATUS_TO_BE_READ;
frontMatter[this.settings.startDateProperty] = "";
frontMatter[this.settings.endDateProperty] = "";
this.app.fileManager.processFrontMatter(file, (fm) => {
fm[this.settings.statusProperty] = STATUS_TO_BE_READ;
fm[this.settings.startDateProperty] = "";
fm[this.settings.endDateProperty] = "";
});
this.readingLog.deleteEntriesForBook(file.basename);

4
src/external/moment.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export type { Moment } from "moment";
// @ts-expect-error Moment is provided by Obsidian
export default moment;

View File

@ -1,4 +1,10 @@
import { Notice, Plugin, requestUrl, TFile } from "obsidian";
import {
Notice,
Plugin,
requestUrl,
TFile,
type FrontMatterCache,
} from "obsidian";
import {
type BookTrackerPluginSettings,
DEFAULT_SETTINGS,
@ -12,7 +18,7 @@ import {
registerReadingLogCodeBlockProcessor,
registerReadingStatsCodeBlockProcessor,
} from "@ui/code-blocks";
import type { Book } from "./types";
import type { Book, BookMetadata, ReadingState } from "./types";
import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
import { LogReadingProgressCommand } from "@commands/LogReadingProgressCommand";
@ -26,6 +32,7 @@ import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock"
import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand";
import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock";
import { registerAToZChallengeCodeBlockProcessor } from "@ui/code-blocks/AToZChallengeCodeBlock";
import moment from "@external/moment";
export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings;
@ -49,20 +56,8 @@ export default class BookTrackerPlugin extends Plugin {
)
);
this.addCommand(new LogReadingStartedCommand(this.app, this.settings));
this.addCommand(
new LogReadingProgressCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addCommand(
new LogReadingFinishedCommand(
this.app,
this.readingLog,
this.settings
)
);
this.addCommand(new LogReadingProgressCommand(this));
this.addCommand(new LogReadingFinishedCommand(this));
this.addCommand(
new ResetReadingStatusCommand(
this.app,
@ -178,4 +173,72 @@ export default class BookTrackerPlugin extends Plugin {
const file = await this.app.vault.create(filePath, renderedContent);
await this.app.workspace.getLeaf().openFile(file);
}
getBookMetadata(file: TFile): BookMetadata | null {
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata) {
return null;
}
return this.frontmatterToMetadata(metadata.frontmatter);
}
frontmatterToMetadata(fm: FrontMatterCache | undefined): BookMetadata {
const getString = (key: string) => {
const value = fm?.[key];
if (typeof value === "string") {
return value;
}
return "";
};
const getStringArray = (key: string) => {
const value = fm?.[key];
if (Array.isArray(value)) {
return value as string[];
}
return [];
};
const getNumber = (key: string) => {
const value = fm?.[key];
if (typeof value === "number") {
return value;
} else if (typeof value === "string") {
return parseFloat(value);
}
return 0;
};
const getDate = (key: string) => {
const value = fm?.[key];
if (typeof value === "string" || value instanceof Date) {
return moment(value);
}
return null;
};
return {
title: getString(this.settings.titleProperty),
subtitle: getString(this.settings.subtitleProperty),
description: getString(this.settings.descriptionProperty),
authors: getStringArray(this.settings.authorsProperty),
seriesTitle: getString(this.settings.seriesTitleProperty),
seriesPosition: getNumber(this.settings.seriesPositionProperty),
startDate: getDate(this.settings.startDateProperty)!,
endDate: getDate(this.settings.endDateProperty)!,
status: getString(this.settings.statusProperty) as ReadingState,
rating: getNumber(this.settings.ratingProperty),
spice: getNumber(this.settings.spiceProperty),
format: getString(this.settings.formatProperty),
source: getStringArray(this.settings.sourceProperty),
categories: getStringArray(this.settings.categoriesProperty),
publisher: getString(this.settings.publisherProperty),
publishDate: getDate(this.settings.publishDateProperty)!,
pageCount: getNumber(this.settings.pageCountProperty),
isbn: getString(this.settings.isbnProperty),
coverImageUrl: getString(this.settings.coverImageUrlProperty),
localCoverPath: getString(this.settings.localCoverPathProperty),
};
}
}

View File

@ -1,12 +1,10 @@
import type {
STATUS_IN_PROGRESS,
STATUS_READ,
STATUS_TO_BE_READ,
} from "./const";
import moment from "@external/moment";
import { STATUS_IN_PROGRESS, STATUS_READ, STATUS_TO_BE_READ } from "./const";
import z from "zod/v4";
export interface Author {
name: string;
description: string;
description?: string;
}
export interface Series {
@ -33,3 +31,28 @@ export type InProgressState = typeof STATUS_IN_PROGRESS;
export type ReadState = typeof STATUS_READ;
export type ReadingState = ToBeReadState | InProgressState | ReadState;
export const BookMetadataSchema = z.object({
title: z.string(),
subtitle: z.string(),
description: z.string(),
authors: z.array(z.string()),
seriesTitle: z.string(),
seriesPosition: z.number(),
startDate: z.date().transform((date) => moment(date)),
endDate: z.date().transform((date) => moment(date)),
status: z.enum([STATUS_TO_BE_READ, STATUS_IN_PROGRESS, STATUS_READ]),
rating: z.number(),
spice: z.number(),
format: z.string(),
source: z.array(z.string()),
categories: z.array(z.string()),
publisher: z.string(),
publishDate: z.date().transform((date) => moment(date)),
pageCount: z.number(),
isbn: z.string(),
coverImageUrl: z.string(),
localCoverPath: z.string(),
});
export type BookMetadata = z.infer<typeof BookMetadataSchema>;

View File

@ -2,7 +2,6 @@ import { registerCodeBlockRenderer } from ".";
import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
import AToZChallengeCodeBlockView from "./AToZChallengeCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import z from "zod/v4";
export function registerAToZChallengeCodeBlockProcessor(
plugin: BookTrackerPlugin
@ -19,8 +18,3 @@ export function registerAToZChallengeCodeBlockProcessor(
)
);
}
export const AToZChallengeSettingsSchema = z.object({
coverProperty: z.string(),
titleProperty: z.string(),
});

View File

@ -4,17 +4,16 @@
setSettingsContext,
} from "@ui/stores/settings.svelte";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
import { createMetadata } from "@ui/stores/metadata.svelte";
import {
createMetadata,
type FileMetadata,
} from "@ui/stores/metadata.svelte";
import { STATUS_READ } from "@src/const";
import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { AToZChallengeSettingsSchema } from "./AToZChallengeCodeBlock";
import { parseYaml, TFile } from "obsidian";
import OpenFileLink from "@ui/components/OpenFileLink.svelte";
import type { Moment } from "moment";
import DateFilter from "@ui/components/DateFilter.svelte";
import BookCover from "@ui/components/BookCover.svelte";
const { plugin, source }: SvelteCodeBlockProps = $props();
const settings = AToZChallengeSettingsSchema.parse(parseYaml(source));
const { plugin }: SvelteCodeBlockProps = $props();
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
@ -39,10 +38,10 @@
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const items = $derived(
const metadata = $derived(
metadataStore.metadata.reduce(
(acc, item) => {
const title = item.frontmatter[settings.titleProperty];
(acc, meta) => {
const title = meta.book.title;
const firstLetter = getSortValue(title).charAt(0).toUpperCase();
if (!firstLetter.match(/[A-Z]/)) {
@ -50,60 +49,23 @@
}
if (!acc[firstLetter]) {
const coverPath = item.frontmatter[
settings.coverProperty
] as string;
const coverFile = plugin.app.vault.getFileByPath(coverPath);
let coverSrc: string = "";
if (coverFile) {
coverSrc = plugin.app.vault.getResourcePath(coverFile);
}
const coverAlt = item.frontmatter[settings.titleProperty];
acc[firstLetter] = {
file: item.file,
// @ts-expect-error Moment is provided by Obsidian
startDate: moment(
item.frontmatter[
settingsStore.settings.startDateProperty
],
),
// @ts-expect-error Moment is provided by Obsidian
endDate: moment(
item.frontmatter[
settingsStore.settings.endDateProperty
],
),
coverSrc,
coverAlt,
};
acc[firstLetter] = meta;
}
return acc;
},
{} as Record<
string,
{
file: TFile;
startDate: Moment;
endDate: Moment;
coverSrc: string;
coverAlt: string;
}
>,
{} as Record<string, FileMetadata>,
),
);
const startDate = $derived(
Object.values(items)
.map((item) => item.startDate)
Object.values(metadata)
.map((meta) => meta.book.startDate)
.sort((a, b) => a.diff(b))[0],
);
const endDate = $derived.by(() => {
const dates = Object.values(items)
.map((item) => item.endDate)
const dates = Object.values(metadata)
.map((meta) => meta.book.endDate)
.sort((a, b) => b.diff(a));
if (dates.length !== 26) {
@ -116,21 +78,17 @@
<div class="reading-bingo">
<div class="top-info">
<select class="year-filter" bind:value={metadataStore.filterYear}>
{#each metadataStore.filterYears as year}
<option value={year}>{year}</option>
{/each}
</select>
<DateFilter store={metadataStore} disableMonthFilter disableAllTime />
<p>Started: {startDate.format("YYYY-MM-DD")}</p>
<p>Ended: {endDate?.format("YYYY-MM-DD") ?? "N/A"}</p>
</div>
<div class="bingo">
{#each alphabet as letter}
<div class="bingo-item">
{#if items[letter]}
{@const item = items[letter]}
<OpenFileLink file={item.file}>
<img src={item.coverSrc} alt={item.coverAlt} />
{#if metadata[letter]}
{@const meta = metadata[letter]}
<OpenFileLink file={meta.file}>
<BookCover app={plugin.app} book={meta.book} />
</OpenFileLink>
{:else}
<div class="placeholder">{letter}</div>
@ -182,7 +140,7 @@
font-style: italic;
}
img {
:global(img) {
width: 100%;
height: 100%;
object-fit: cover;

View File

@ -2,7 +2,6 @@ import { registerCodeBlockRenderer } from ".";
import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
import ReadingCalendarCodeBlockView from "./ReadingCalendarCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import z from "zod/v4";
export function registerReadingCalendarCodeBlockProcessor(
plugin: BookTrackerPlugin
@ -19,7 +18,3 @@ export function registerReadingCalendarCodeBlockProcessor(
)
);
}
export const ReadingCalendarSettingsSchema = z.object({
coverProperty: z.string(),
});

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { parseYaml, TFile } from "obsidian";
import { ReadingCalendarSettingsSchema } from "./ReadingCalendarCodeBlock";
import { TFile } from "obsidian";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
import {
createSettings,
@ -9,6 +8,7 @@
import {
createMetadata,
setMetadataContext,
type FileMetadata,
} from "@ui/stores/metadata.svelte";
import {
createReadingLog,
@ -17,10 +17,10 @@
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import { onDestroy, onMount } from "svelte";
import OpenFileLink from "@ui/components/OpenFileLink.svelte";
import moment from "@external/moment";
import BookCover from "@ui/components/BookCover.svelte";
const { plugin, source }: SvelteCodeBlockProps = $props();
const settings = ReadingCalendarSettingsSchema.parse(parseYaml(source));
const { plugin }: SvelteCodeBlockProps = $props();
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
@ -66,17 +66,14 @@
"Saturday",
];
// @ts-expect-error Moment is provided by Obsidian
let today = $state(moment());
function msUntilMidnight() {
// @ts-expect-error Moment is provided by Obsidian
return moment().endOf("day").diff(today, "milliseconds");
}
function updateToday() {
setTimeout(() => {
// @ts-expect-error Moment is provided by Obsidian
today = moment();
updateToday();
}, msUntilMidnight() + 1000);
@ -92,14 +89,12 @@
});
const weeks = $derived.by(() => {
// @ts-expect-error Moment is provided by Obsidian
const firstDay = moment()
.year(year)
.month(month)
.startOf("month")
.startOf("week");
// @ts-expect-error Moment is provided by Obsidian
const lastDay = moment()
.year(year)
.month(month)
@ -120,15 +115,9 @@
return weeks;
});
interface BookData {
coverSrc: string;
coverAlt: string;
file: TFile;
}
interface BookMapItem {
totalPagesRead: number;
books: BookData[];
metadata: FileMetadata[];
}
const bookMap = $derived(
@ -136,30 +125,17 @@
(acc, entry) => {
const key = entry.createdAt.date();
const metadata = metadataStore.metadata.find(
const meta = metadataStore.metadata.find(
(other) => other.file.basename === entry.book,
);
if (!metadata) {
if (!meta) {
return acc;
}
const coverPath = metadata.frontmatter?.[
settings.coverProperty
] as string;
const coverFile = plugin.app.vault.getFileByPath(coverPath);
let coverSrc = "";
if (coverFile) {
coverSrc = plugin.app.vault.getResourcePath(coverFile);
}
const value = acc[key] ?? { totalPagesRead: 0, books: [] };
const value = acc[key] ?? { totalPagesRead: 0, metadata: [] };
value.totalPagesRead += entry.pagesRead;
value.books.push({
coverSrc,
coverAlt: entry.book,
file: metadata.file,
});
value.metadata.push(meta);
acc[key] = value;
return acc;
@ -248,12 +224,12 @@
<div class="covers">
{#if isThisMonth && date in bookMap}
{@const data = bookMap[date]}
{#each data.books as book}
{#each data.metadata as meta}
<div class="cover">
<OpenFileLink file={book.file}>
<img
src={book.coverSrc}
alt={book.coverAlt}
<OpenFileLink file={meta.file}>
<BookCover
app={plugin.app}
book={meta.book}
/>
</OpenFileLink>
</div>
@ -340,7 +316,7 @@
height: 100%;
width: 100%;
img {
:global(img) {
border-radius: var(--radius-l);
width: 100%;
height: 100%;

View File

@ -4,6 +4,7 @@ import ReadingStatsCodeBlockView from "./ReadingStatsCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import * as z from "zod/v4";
import { COLOR_NAMES } from "@utils/color";
import { BookMetadataSchema } from "@src/types";
export function registerReadingStatsCodeBlockProcessor(
plugin: BookTrackerPlugin
@ -41,7 +42,7 @@ export type PieChartColor = z.infer<typeof PieChartColorSchema>;
const PieChart = z.object({
type: z.literal("pie"),
property: z.string(),
property: z.keyof(BookMetadataSchema),
unit: z.optional(z.string()),
unitPlural: z.optional(z.string()),
groups: z.optional(z.array(PieGroupingSchema)),
@ -51,7 +52,7 @@ const PieChart = z.object({
const BarChart = z.object({
type: z.literal("bar"),
property: z.string(),
property: z.keyof(BookMetadataSchema),
horizontal: z.optional(z.boolean()),
sortByLabel: z.optional(z.boolean()),
topN: z.optional(z.number()),
@ -63,10 +64,10 @@ const BarChart = z.object({
const LineChart = z.object({
type: z.literal("line"),
property: z.string(),
property: z.keyof(BookMetadataSchema),
unit: z.optional(z.string()),
unitPlural: z.optional(z.string()),
secondProperty: z.optional(z.string()),
secondProperty: z.optional(z.keyof(BookMetadataSchema)),
secondUnit: z.optional(z.string()),
secondUnitPlural: z.optional(z.string()),
responsive: z.optional(z.boolean()),
@ -84,7 +85,7 @@ export const ReadingStatsSectionSchema = z.object({
z.object({
type: z.enum(["count", "average", "total"]),
label: z.string(),
property: z.string(),
property: z.keyof(BookMetadataSchema),
}),
z.object({
type: z.literal("book-count"),

View File

@ -24,13 +24,6 @@ export const ShelfSettingsSchema = z.object({
.enum([STATUS_TO_BE_READ, STATUS_IN_PROGRESS, STATUS_READ])
.default(STATUS_TO_BE_READ),
defaultView: z.enum(SHELF_VIEWS).default("table"),
coverProperty: z.string(),
titleProperty: z.string(),
subtitleProperty: z.optional(z.string()),
authorsProperty: z.string(),
descriptionProperty: z.optional(z.string()),
seriesTitleProperty: z.optional(z.string()),
seriesNumberProperty: z.optional(z.string()),
});
export type ShelfSettings = z.infer<typeof ShelfSettingsSchema>;

View File

@ -54,7 +54,7 @@
{/if}
</div>
{#if view === "bookshelf"}
<BookshelfView {plugin} {settings} />
<BookshelfView {plugin} />
{:else if view === "table"}
<TableView {plugin} {settings} />
{:else if view === "details"}

View File

@ -0,0 +1,27 @@
<script lang="ts">
import type { BookMetadata } from "@src/types";
import type { App } from "obsidian";
interface Props {
app: App;
book: BookMetadata;
size?: number;
}
const { app, book, size }: Props = $props();
const coverPath = $derived(book.localCoverPath);
const coverFile = $derived(app.vault.getFileByPath(coverPath));
const coverSrc = $derived(
coverFile ? app.vault.getResourcePath(coverFile) : "",
);
const coverAlt = $derived(book.title);
</script>
<img src={coverSrc} alt={coverAlt} width={size} />
<style lang="scss">
img {
border-radius: var(--radius-l);
}
</style>

View File

@ -7,17 +7,14 @@
getMetadataContext,
type FileMetadata,
} from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { COLOR_NAMES, type ColorName } from "@utils/color";
import { randomElement, randomFloat } from "@utils/rand";
import { v4 as uuidv4 } from "uuid";
import memoize from "just-memoize";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
import type { TFile } from "obsidian";
interface Props {
plugin: BookTrackerPlugin;
settings: ShelfSettings;
}
interface BookData {
@ -37,9 +34,8 @@
books: BookData[];
}
const { plugin, settings }: Props = $props();
const { plugin }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
const designs = ["default", "dual-top-bands", "split-bands"] as const;
@ -74,29 +70,20 @@
}
const getBookData = memoize(
(metadata: FileMetadata): BookData => {
(meta: FileMetadata): BookData => {
const orientation = randomOrientation();
return {
id: metadata.file.path,
title: metadata.frontmatter[settings.titleProperty],
subtitle: settings.subtitleProperty
? metadata.frontmatter[settings.subtitleProperty]
: undefined,
authors: metadata.frontmatter[settings.authorsProperty],
width: Math.min(
Math.max(
20,
metadata.frontmatter[
settingsStore.settings.pageCountProperty
] / 10,
),
100,
),
id: meta.file.path,
title: meta.book.title,
subtitle:
meta.book.subtitle === "" ? undefined : meta.book.subtitle,
authors: meta.book.authors,
width: Math.min(Math.max(20, meta.book.pageCount / 10), 100),
color: randomColor(),
design: orientation === "front" ? "default" : randomDesign(),
orientation: randomOrientation(),
file: metadata.file,
file: meta.file,
};
},
(metadata: FileMetadata) => metadata.file.path,

View File

@ -10,20 +10,27 @@
"filterYear" | "filterYears" | "filterMonth" | "filterMonths"
>;
showAllMonths?: boolean;
disableMonthFilter?: boolean;
disableAllTime?: boolean;
}
const { store, showAllMonths }: Props = $props();
const { store, showAllMonths, disableMonthFilter, disableAllTime }: Props =
$props();
</script>
<select class="year-filter" bind:value={store.filterYear}>
{#each store.filterYears as year}
<option value={year}>{year}</option>
{/each}
<option value={ALL_TIME}>All Time</option>
{#if !disableAllTime}
<option value={ALL_TIME}>All Time</option>
{/if}
</select>
{#if store.filterYear !== ALL_TIME}
{#if store.filterYear !== ALL_TIME && !disableMonthFilter}
<select class="month-filter" bind:value={store.filterMonth}>
<option value={ALL_TIME}>Select Month</option>
{#if disableAllTime}
<option value={ALL_TIME}>Select Month</option>
{/if}
{#if showAllMonths}
<option value={1}>January</option>
<option value={2}>February</option>

View File

@ -2,12 +2,11 @@
import { STATUS_IN_PROGRESS, STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { getLinkpath } from "obsidian";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
import { Dot, Flame, Star, StarHalf } from "lucide-svelte";
import RatingInput from "./RatingInput.svelte";
import OpenFileLink from "./OpenFileLink.svelte";
import BookCover from "./BookCover.svelte";
interface Props {
plugin: BookTrackerPlugin;
@ -16,79 +15,54 @@
const { plugin, settings }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
</script>
<div class="book-details-list">
{#each metadataStore.metadata as book}
{@const coverPath = book.frontmatter[settings.coverProperty]}
{@const title = book.frontmatter[settings.titleProperty]}
{@const subtitle = settings.subtitleProperty
? book.frontmatter[settings.subtitleProperty]
: undefined}
{@const authors = book.frontmatter[settings.authorsProperty]}
{@const description = settings.descriptionProperty
? book.frontmatter[settings.descriptionProperty]
: undefined}
{@const seriesTitle = settings.seriesTitleProperty
? book.frontmatter[settings.seriesTitleProperty]
: undefined}
{@const seriesNumber = settings.seriesNumberProperty
? book.frontmatter[settings.seriesNumberProperty]
: undefined}
{@const startDate =
book.frontmatter[settingsStore.settings.startDateProperty]}
{@const endDate =
book.frontmatter[settingsStore.settings.endDateProperty]}
{@const rating =
book.frontmatter[settingsStore.settings.ratingProperty] ?? 0}
{@const spice =
book.frontmatter[settingsStore.settings.spiceProperty] ?? 0}
{#each metadataStore.metadata as meta}
<div class="book-details">
<img
src={plugin.app.vault.getResourcePath(
plugin.app.vault.getFileByPath(coverPath)!,
)}
alt={title}
/>
<BookCover app={plugin.app} book={meta.book} />
<div class="book-info">
<OpenFileLink file={book.file}>
<OpenFileLink file={meta.file}>
<h2 class="book-title">
{title}
{meta.book.title}
</h2>
</OpenFileLink>
{#if subtitle}
<p class="subtitle">{subtitle}</p>
{#if meta.book.subtitle !== ""}
<p class="subtitle">{meta.book.subtitle}</p>
{/if}
<p class="authors">By: {authors.join(", ")}</p>
{#if seriesTitle}
<p class="authors">By: {meta.book.authors.join(", ")}</p>
{#if meta.book.seriesTitle != ""}
<p class="series">
<span class="series-title">{seriesTitle}</span>
{#if seriesNumber}
<span class="series-number">#{seriesNumber}</span>
{/if}
<span class="series-title">{meta.book.seriesTitle}</span
>
<span class="series-number">
#{meta.book.seriesPosition}
</span>
</p>
{/if}
{#if description}
{#if meta.book.description != ""}
<hr />
<p class="description">{@html description}</p>
<p class="description">{@html meta.book.description}</p>
<hr />
{/if}
<div class="footer">
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<p class="start-date">
Started:
<datetime datetime={startDate}>{startDate}</datetime
<datetime
datetime={meta.book.startDate.format("LLLL")}
title={meta.book.startDate.format("LLLL")}
>
{meta.book.startDate.format("l")}
</datetime>
</p>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS}
<Dot color="var(--text-muted)" />
<p class="current-page">
Current Page: {plugin.readingLog.getLastEntryForBook(
book.file.basename,
meta.file.basename,
)?.pagesReadTotal ?? 0}
</p>
{/if}
@ -97,10 +71,19 @@
<Dot color="var(--text-muted)" />
<p class="end-date">
Finished:
<datetime datetime={endDate}>{endDate}</datetime>
<datetime
datetime={meta.book.endDate.format("LLLL")}
title={meta.book.endDate.format("LLLL")}
>
{meta.book.endDate.format("l")}
</datetime>
</p>
<Dot color="var(--text-muted)" />
<RatingInput value={rating} disabled {iconSize}>
<RatingInput
value={meta.book.rating}
disabled
{iconSize}
>
{#snippet inactive()}
<Star
color="var(--background-modifier-border)"
@ -122,7 +105,11 @@
/>
{/snippet}
</RatingInput>
<RatingInput value={spice} disabled {iconSize}>
<RatingInput
value={meta.book.spice}
disabled
{iconSize}
>
{#snippet inactive()}
<Flame
color="var(--background-modifier-border)"
@ -157,7 +144,7 @@
background-color: var(--background-secondary);
border-radius: var(--radius-l);
img {
:global(img) {
border-radius: var(--radius-l);
max-width: 30%;
}
@ -166,7 +153,7 @@
flex-direction: column;
align-items: center;
img {
:global(img) {
max-height: 30rem;
max-width: 100%;
}

View File

@ -2,11 +2,10 @@
import { STATUS_IN_PROGRESS, STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { getLinkpath } from "obsidian";
import Rating from "@ui/components/Rating.svelte";
import type { ShelfSettings } from "@ui/code-blocks/ShelfCodeBlock";
import OpenFileLink from "./OpenFileLink.svelte";
import BookCover from "./BookCover.svelte";
interface Props {
plugin: BookTrackerPlugin;
@ -15,7 +14,6 @@
const { plugin, settings }: Props = $props();
const settingsStore = getSettingsContext();
const metadataStore = getMetadataContext();
</script>
@ -25,12 +23,8 @@
<th>Cover</th>
<th>Title</th>
<th>Authors</th>
{#if settings.seriesTitleProperty}
<th>Series</th>
{/if}
{#if settings.seriesNumberProperty}
<th>#</th>
{/if}
<th>Series</th>
<th>#</th>
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<th>Start Date</th>
{/if}
@ -41,64 +35,46 @@
</tr>
</thead>
<tbody>
{#each metadataStore.metadata as book}
{@const coverPath = book.frontmatter[settings.coverProperty]}
{@const title = book.frontmatter[settings.titleProperty]}
{@const authors = book.frontmatter[settings.authorsProperty]}
{@const seriesTitle = settings.seriesTitleProperty
? book.frontmatter[settings.seriesTitleProperty]
: undefined}
{@const seriesNumber = settings.seriesNumberProperty
? book.frontmatter[settings.seriesNumberProperty]
: undefined}
{@const startDate =
book.frontmatter[settingsStore.settings.startDateProperty]}
{@const endDate =
book.frontmatter[settingsStore.settings.endDateProperty]}
{@const rating =
book.frontmatter[settingsStore.settings.ratingProperty] ?? 0}
{@const spice =
book.frontmatter[settingsStore.settings.spiceProperty] ?? 0}
{#each metadataStore.metadata as meta}
<tr>
<td>
<img
src={plugin.app.vault.getResourcePath(
plugin.app.vault.getFileByPath(coverPath)!,
)}
alt={title}
width="50"
/>
<td class="cover">
<BookCover app={plugin.app} book={meta.book} size={50} />
</td>
<td>
<OpenFileLink file={book.file}>
{title}
<OpenFileLink file={meta.file}>
{meta.book.title}
</OpenFileLink>
</td>
<td>
{authors.join(", ")}
{meta.book.authors.join(", ")}
</td>
<td>
{meta.book.seriesTitle}
</td>
<td>
{meta.book.seriesPosition}
</td>
{#if settings.seriesTitleProperty}
<td>
{#if seriesTitle}{seriesTitle}{/if}
</td>
{/if}
{#if settings.seriesNumberProperty}
<td>
{#if seriesNumber}{seriesNumber}{/if}
</td>
{/if}
{#if settings.statusFilter === STATUS_IN_PROGRESS || settings.statusFilter === STATUS_READ}
<td>
<datetime datetime={startDate}>{startDate}</datetime>
<datetime
datetime={meta.book.startDate.format("LLLL")}
title={meta.book.startDate.format("LLLL")}
>
{meta.book.startDate.format("ll")}
</datetime>
</td>
{/if}
{#if settings.statusFilter === STATUS_READ}
<td>
<datetime datetime={endDate}>{endDate}</datetime>
<datetime
datetime={meta.book.endDate.format("LLLL")}
title={meta.book.endDate.format("LLLL")}
>
{meta.book.endDate.format("ll")}
</datetime>
</td>
<td>
<Rating {rating} />
<Rating rating={meta.book.rating} />
</td>
{/if}
</tr>
@ -110,5 +86,15 @@
table {
border-collapse: collapse;
width: 100%;
td {
text-align: center;
vertical-align: middle;
&.cover {
padding: 0;
margin: 0;
}
}
}
</style>

View File

@ -1,11 +1,12 @@
<script lang="ts">
import type { BookMetadata } from "@src/types";
import { chart } from "@ui/directives/chart";
import { createPropertyStore } from "@ui/stores/metadata.svelte";
import { Color, type ColorName } from "@utils/color";
import type { ChartConfiguration } from "chart.js";
type Props = {
property: string;
property: keyof BookMetadata;
horizontal?: boolean;
sortByLabel?: boolean;
topN?: number;

View File

@ -3,11 +3,10 @@
import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getReadingLogContext } from "@ui/stores/reading-log.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte";
import { Color } from "@utils/color";
import type { ChartConfiguration } from "chart.js";
import moment from "@external/moment";
const settingsStore = getSettingsContext();
const store = getMetadataContext();
const readingLog = getReadingLogContext();
@ -22,12 +21,8 @@
date: entry.createdAt,
}))
: store.metadata.map((f) => ({
pageCount:
f.frontmatter[settingsStore.settings.pageCountProperty],
// @ts-expect-error Moment is provided by Obsidian
date: moment(
f.frontmatter[settingsStore.settings.endDateProperty],
),
pageCount: f.book.pageCount,
date: f.book.endDate,
})),
);
@ -52,7 +47,6 @@
}
if (isMonthly && typeof store.filterMonth === "number") {
// @ts-expect-error Moment is provided by Obsidian
const daysInMonth = moment()
.month(store.filterMonth - 1)
.daysInMonth();
@ -70,8 +64,7 @@
.map((key) =>
store.filterYear === ALL_TIME || isMonthly
? key
: // @ts-expect-error Moment is provided by Obsidian
moment().month(key).format("MMM"),
: moment().month(key).format("MMM"),
);
const sortedBooks = Array.from(books.entries())
.sort((a, b) => a[0] - b[0])

View File

@ -1,4 +1,5 @@
<script lang="ts">
import type { BookMetadata } from "@src/types";
import type {
PieChartColor,
PieGrouping,
@ -9,7 +10,7 @@
import type { ChartConfiguration } from "chart.js";
type Props = {
property: string;
property: keyof BookMetadata;
groups?: PieGrouping[];
unit?: string;
unitPlural?: string;

View File

@ -2,7 +2,7 @@
import type { ComponentProps } from "svelte";
import Item from "./Item.svelte";
import type { App } from "obsidian";
import FieldSuggest from "../suggesters/FieldSuggest.svelte";
import PropertySuggest from "../suggesters/PropertySuggest.svelte";
type Props = Omit<ComponentProps<typeof Item>, "control"> & {
app: App;
@ -12,9 +12,9 @@
};
let {
app,
name,
description,
app,
id,
value = $bindable(),
accepts,
@ -23,6 +23,6 @@
<Item {name} {description}>
{#snippet control()}
<FieldSuggest {id} {app} asString bind:value {accepts} />
<PropertySuggest {app} {id} asString bind:value {accepts} />
{/snippet}
</Item>

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { createPropertyStore } from "@ui/stores/metadata.svelte";
import Stat from "./Stat.svelte";
import type { BookMetadata } from "@src/types";
type Props = {
label: string;
property: string;
property: keyof BookMetadata;
};
const { label, property }: Props = $props();

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { createPropertyStore } from "@ui/stores/metadata.svelte";
import Stat from "./Stat.svelte";
import type { BookMetadata } from "@src/types";
type Props = {
label: string;
property: string;
property: keyof BookMetadata;
};
const { label, property }: Props = $props();

View File

@ -1,10 +1,11 @@
<script lang="ts">
import { createPropertyStore } from "@ui/stores/metadata.svelte";
import Stat from "./Stat.svelte";
import type { BookMetadata } from "@src/types";
type Props = {
label: string;
property: string;
property: keyof BookMetadata;
};
const { label, property }: Props = $props();

View File

@ -32,8 +32,8 @@
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 typesContent = await app.vault.adapter.read(
app.vault.configDir + "/types.json",
);
const types = JSON.parse(typesContent).types as Record<string, string>;

View File

@ -9,6 +9,7 @@
createSettings,
setSettingsContext,
} from "@ui/stores/settings.svelte";
import moment from "@external/moment";
const INPUT_DATETIME_FORMAT = "YYYY-MM-DDTHH:mm";
@ -38,7 +39,6 @@
let pagesRemaining = $state(entry?.pagesRemaining ?? 0);
let createdAt = $state(
entry?.createdAt?.format(INPUT_DATETIME_FORMAT) ??
// @ts-expect-error Moment is provided by Obsidian
moment().format(INPUT_DATETIME_FORMAT),
);
@ -49,10 +49,7 @@
});
$effect(() => {
pagesRemaining =
(bookMetadata?.frontmatter?.[
settingsStore.settings.pageCountProperty
] ?? 0) - pagesReadTotal;
pagesRemaining = bookMetadata?.book.pageCount ?? 0 - pagesReadTotal;
});
$effect(() => {
@ -71,7 +68,6 @@
pagesRead,
pagesReadTotal,
pagesRemaining,
// @ts-expect-error Moment is provided by Obsidian
createdAt: moment(createdAt),
});
}

View File

@ -3,11 +3,12 @@
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 PropertySuggestItem from "@ui/components/setting/PropertySuggestItem.svelte";
import ToggleItem from "@ui/components/setting/ToggleItem.svelte";
import type BookTrackerPlugin from "@src/main";
import { createSettings } from "@ui/stores/settings.svelte";
import { onMount } from "svelte";
import type { BookTrackerSettings } from "./types";
type Props = {
plugin: BookTrackerPlugin;
@ -19,6 +20,149 @@
const settingsStore = createSettings(plugin);
onMount(async () => settingsStore.load());
interface Property {
label: string;
description: string;
key: keyof BookTrackerSettings;
type: "text" | "multitext" | "number" | "date";
}
const properties: Property[] = [
{
label: "Title",
description: "The property which contains the book's title.",
key: "titleProperty",
type: "text",
},
{
label: "Subtitle",
description: "The property which contains the book's subtitle.",
key: "subtitleProperty",
type: "text",
},
{
label: "Description",
description:
"The property which contains the description/blurb of the book.",
key: "descriptionProperty",
type: "text",
},
{
label: "Authors",
description:
"The property which contains the list of the book's author names.",
key: "authorsProperty",
type: "multitext",
},
{
label: "Series Title",
description:
"The property which contains the title of the series the book belongs to.",
key: "seriesTitleProperty",
type: "text",
},
{
label: "Series Position",
description:
"The property which contains the position of the book in the series.",
key: "seriesPositionProperty",
type: "number",
},
{
label: "Start Date",
description:
"The property where the book's start date will be stored.",
key: "startDateProperty",
type: "date",
},
{
label: "End Date",
description:
"The property where the book's end date will be stored.",
key: "endDateProperty",
type: "date",
},
{
label: "Status",
description:
"The property which contains the book's reading status.",
key: "statusProperty",
type: "text",
},
{
label: "Rating",
description:
"The property where your rating of the book will be stored.",
key: "ratingProperty",
type: "number",
},
{
label: "Spice",
description: `The property where your spice rating of the book will be stored.
Set to empty to if you're not interested in this feature.`,
key: "spiceProperty",
type: "number",
},
{
label: "Format",
description: `The property which contains the book's format.
(e.g. E-Book, Audiobook, Physical, etc.)`,
key: "formatProperty",
type: "text",
},
{
label: "Source",
description: `The property which contains the where you obtained the book.
(e.g. Amazon, Library, Bookstore, etc.)`,
key: "sourceProperty",
type: "multitext",
},
{
label: "Categories",
description: "The property which contains the book's categories.",
key: "categoriesProperty",
type: "multitext",
},
{
label: "Publisher",
description: "The property which contains the book's publisher.",
key: "publisherProperty",
type: "text",
},
{
label: "Publish Date",
description: "The property which contains the book's publish date.",
key: "publishDateProperty",
type: "date",
},
{
label: "Page Count",
description: "The property which contains the book's page count.",
key: "pageCountProperty",
type: "number",
},
{
label: "ISBN",
description: "The property which contains the book's ISBN.",
key: "isbnProperty",
type: "text",
},
{
label: "Cover Image URL",
description:
"The property which contains the book's cover image URL.",
key: "coverImageUrlProperty",
type: "text",
},
{
label: "Local Cover Path",
description:
"The property which contains the book's local cover path.",
key: "localCoverPathProperty",
type: "text",
},
];
</script>
<div class="obt-settings">
@ -93,54 +237,15 @@
bind:checked={settingsStore.settings.overwriteExistingCovers}
/>
<Header title="Reading Log" />
<FieldSuggestItem
{app}
id="status-field"
name="Status Field"
description="Select the field to use for reading status."
bind:value={settingsStore.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={settingsStore.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={settingsStore.settings.endDateProperty}
accepts={["date"]}
/>
<FieldSuggestItem
{app}
id="rating-field"
name="Rating Field"
description="Select the field to use for rating."
bind:value={settingsStore.settings.ratingProperty}
accepts={["number"]}
/>
<FieldSuggestItem
{app}
id="spice-field"
name="Spice Field"
description={`Select the field to use for spice rating.
Set to empty to disable.`}
bind:value={settingsStore.settings.spiceProperty}
accepts={["number"]}
/>
<FieldSuggestItem
{app}
id="page-count-field"
name="Page Count Field"
description="Select the field to use for page count."
bind:value={settingsStore.settings.pageCountProperty}
accepts={["number"]}
/>
<Header title="Book Properties" />
{#each properties as property}
<PropertySuggestItem
{app}
id={property.key}
name={`${property.label} Property`}
description={property.description}
bind:value={settingsStore.settings[property.key] as string}
accepts={[property.type]}
/>
{/each}
</div>

View File

@ -9,12 +9,26 @@ export interface BookTrackerSettings {
coverFolder: string;
groupCoversByFirstLetter: boolean;
overwriteExistingCovers: boolean;
statusProperty: string;
titleProperty: string;
subtitleProperty: string;
descriptionProperty: string;
authorsProperty: string;
seriesTitleProperty: string;
seriesPositionProperty: string;
startDateProperty: string;
endDateProperty: string;
statusProperty: string;
ratingProperty: string;
spiceProperty: string;
formatProperty: string;
sourceProperty: string;
categoriesProperty: string;
publisherProperty: string;
publishDateProperty: string;
pageCountProperty: string;
isbnProperty: string;
coverImageUrlProperty: string;
localCoverPathProperty: string;
}
export const DEFAULT_SETTINGS: BookTrackerSettings = {
@ -28,10 +42,24 @@ export const DEFAULT_SETTINGS: BookTrackerSettings = {
coverFolder: "images/covers",
groupCoversByFirstLetter: true,
overwriteExistingCovers: false,
statusProperty: "status",
titleProperty: "title",
subtitleProperty: "subtitle",
descriptionProperty: "description",
authorsProperty: "authors",
seriesTitleProperty: "seriesTitle",
seriesPositionProperty: "seriesPosition",
startDateProperty: "startDate",
endDateProperty: "endDate",
statusProperty: "status",
ratingProperty: "rating",
spiceProperty: "",
formatProperty: "type",
sourceProperty: "source",
categoriesProperty: "categories",
publisherProperty: "publisher",
publishDateProperty: "publishDate",
pageCountProperty: "pageCount",
isbnProperty: "isbn",
coverImageUrlProperty: "coverImageUrl",
localCoverPathProperty: "localCoverPath",
};

View File

@ -1,4 +1,4 @@
import type { Moment } from "moment";
import type { Moment } from "@external/moment";
export const ALL_TIME = "ALL_TIME";

View File

@ -1,23 +1,17 @@
import { STATUS_READ } from "@src/const";
import type { CachedMetadata, TFile } from "obsidian";
import { getContext, setContext } from "svelte";
import {
createSettings,
getSettingsContext,
setSettingsContext,
} from "./settings.svelte";
import type BookTrackerPlugin from "@src/main";
import {
createDateFilter,
type DateFilterStore,
type DateFilterStoreOptions,
} from "./date-filter.svelte";
import type { ReadingState } from "@src/types";
import type { BookTrackerPluginSettings } from "@ui/settings";
import type { BookMetadata, ReadingState } from "@src/types";
export type FileMetadata = {
file: TFile;
frontmatter: Record<string, any>;
book: BookMetadata;
};
export type FileProperty = {
@ -33,22 +27,20 @@ export interface MetadataStore extends DateFilterStore {
function getMetadata(
plugin: BookTrackerPlugin,
settings: BookTrackerPluginSettings,
state: ReadingState | null
): FileMetadata[] {
const metadata: FileMetadata[] = [];
for (const file of plugin.app.vault.getMarkdownFiles()) {
const frontmatter =
plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {};
if (
!(settings.statusProperty in frontmatter) ||
(state !== null && frontmatter[settings.statusProperty] !== state)
) {
const book = plugin.getBookMetadata(file);
if (!book) {
continue;
}
metadata.push({ file, frontmatter });
if (state && book.status !== state) {
continue;
}
metadata.push({ file, book });
}
return metadata;
}
@ -64,29 +56,19 @@ export function createMetadata(
plugin: BookTrackerPlugin,
{ statusFilter = STATUS_READ, ...dateFilterOpts }: MetadataStoreOptions = {}
): MetadataStore {
let settingsStore = getSettingsContext();
if (!settingsStore) {
settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
}
const initialMetadata = getMetadata(
plugin,
settingsStore.settings,
statusFilter
);
const initialMetadata = getMetadata(plugin, statusFilter);
let metadata: FileMetadata[] = $state(initialMetadata);
$effect(() => {
metadata = getMetadata(plugin, settingsStore.settings, statusFilter);
metadata = getMetadata(plugin, statusFilter);
});
function onChanged(file: TFile, _data: string, cache: CachedMetadata) {
metadata = metadata.map((f) => {
if (f.file.path === file.path) {
return {
...f,
frontmatter: cache.frontmatter ?? {},
file: f.file,
book: plugin.frontmatterToMetadata(cache.frontmatter),
};
}
return f;
@ -101,12 +83,7 @@ export function createMetadata(
const dateFilter = createDateFilter(
() => metadata,
(f) => {
// @ts-expect-error Moment is provided by Obsidian
return moment(
f.frontmatter[settingsStore.settings.endDateProperty]
);
},
(f) => f.book.endDate,
dateFilterOpts
);
@ -159,15 +136,15 @@ interface PropertyStore {
get propertyData(): FileProperty[];
}
export function createPropertyStore(
property: string,
filter: (value: any) => boolean = notEmpty
export function createPropertyStore<T extends keyof BookMetadata>(
property: T,
filter: (value: BookMetadata[T]) => boolean = notEmpty
): PropertyStore {
const store = getMetadataContext();
const propertyData = $derived(
store.metadata
.map((f) => ({ ...f, value: f.frontmatter[property] }))
.map((f) => ({ file: f.file, value: f.book[property] }))
.filter((f) => (filter ? filter(f.value) : true))
);

View File

@ -1,5 +1,5 @@
import type { Storage } from "./Storage";
import type { Moment } from "moment";
import moment, { type Moment } from "@external/moment";
import { v4 as uuidv4 } from "uuid";
import { EventEmitter } from "./event";
@ -47,7 +47,6 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
if (entries) {
this.entries = entries.map((entry) => ({
...entry,
// @ts-expect-error Moment is provided by Obsidian
createdAt: moment(entry.createdAt),
}));
this.emit("load", { entries: this.entries });
@ -66,7 +65,6 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
filename,
this.entries.map((entry) => ({
...entry,
// @ts-expect-error Moment is provided by Obsidian
createdAt: moment(entry.createdAt).toISOString(true),
}))
);
@ -107,7 +105,6 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
: pageEnded,
pagesReadTotal: pageEnded,
pagesRemaining: pageCount - pageEnded,
// @ts-expect-error Moment is provided by Obsidian
createdAt: moment(),
};

View File

@ -23,6 +23,7 @@
"paths": {
"@commands/*": ["src/commands/*"],
"@data-sources/*": ["src/data-sources/*"],
"@external/*": ["src/external/*"],
"@ui/*": ["src/ui/*"],
"@utils/*": ["src/utils/*"],
"@src/*": ["src/*"]