diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index f85614d..fe0877a 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -54,7 +54,11 @@ const context = await esbuild.context({
plugins: [
esbuildSvelte({
preprocess: sveltePreprocess(),
- compilerOptions: { dev: !prod },
+ compilerOptions: {
+ dev: !prod,
+ warningFilter: (warning) =>
+ !warning.filename?.includes("node_modules"),
+ },
}),
{
name: "copy-plugin",
diff --git a/package.json b/package.json
index 1049175..938c61d 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.30.0",
"@popperjs/core": "^2.11.8",
+ "@types/chroma-js": "^3.1.1",
"@types/node": "^24.0.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
@@ -29,6 +30,7 @@
"globals": "^16.2.0",
"handlebars": "^4.7.8",
"lucide-svelte": "^0.525.0",
+ "moment": "^2.30.1",
"npm-run-all": "^4.1.5",
"obsidian": "latest",
"runed": "^0.29.1",
@@ -39,5 +41,11 @@
"svelte-preprocess": "^6.0.3",
"tslib": "2.4.0",
"typescript": "5.0.4"
+ },
+ "dependencies": {
+ "chart.js": "^4.5.0",
+ "chroma-js": "^3.1.2",
+ "yaml": "^2.8.0",
+ "zod": "^3.25.67"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e6241a..b71b8ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,19 @@ settings:
importers:
.:
+ dependencies:
+ chart.js:
+ specifier: ^4.5.0
+ version: 4.5.0
+ chroma-js:
+ specifier: ^3.1.2
+ version: 3.1.2
+ yaml:
+ specifier: ^2.8.0
+ version: 2.8.0
+ zod:
+ specifier: ^3.25.67
+ version: 3.25.67
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.1
@@ -17,6 +30,9 @@ importers:
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
+ '@types/chroma-js':
+ specifier: ^3.1.1
+ version: 3.1.1
'@types/node':
specifier: ^24.0.6
version: 24.0.6
@@ -53,6 +69,9 @@ importers:
lucide-svelte:
specifier: ^0.525.0
version: 0.525.0(svelte@5.34.8)
+ moment:
+ specifier: ^2.30.1
+ version: 2.30.1
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@@ -308,6 +327,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@kurkle/color@0.3.4':
+ resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
+
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -413,6 +435,9 @@ packages:
peerDependencies:
acorn: ^8.9.0
+ '@types/chroma-js@3.1.1':
+ resolution: {integrity: sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==}
+
'@types/codemirror@5.60.8':
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
@@ -576,10 +601,17 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chart.js@4.5.0:
+ resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
+ engines: {pnpm: '>=8'}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
+ chroma-js@3.1.2:
+ resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1124,6 +1156,9 @@ packages:
moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
+ moment@2.30.1:
+ resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -1595,6 +1630,11 @@ packages:
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
+ yaml@2.8.0:
+ resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1602,6 +1642,9 @@ packages:
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
+ zod@3.25.67:
+ resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==}
+
snapshots:
'@ampproject/remapping@2.3.0':
@@ -1764,6 +1807,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@kurkle/color@0.3.4': {}
+
'@marijn/find-cluster-break@1.0.2': {}
'@nodelib/fs.scandir@2.1.5':
@@ -1845,6 +1890,8 @@ snapshots:
dependencies:
acorn: 8.15.0
+ '@types/chroma-js@3.1.1': {}
+
'@types/codemirror@5.60.8':
dependencies:
'@types/tern': 0.23.9
@@ -2034,10 +2081,16 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chart.js@4.5.0:
+ dependencies:
+ '@kurkle/color': 0.3.4
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
+ chroma-js@3.1.2: {}
+
clsx@2.1.1: {}
color-convert@1.9.3:
@@ -2678,6 +2731,8 @@ snapshots:
moment@2.29.4: {}
+ moment@2.30.1: {}
+
mri@1.2.0: {}
ms@2.1.3: {}
@@ -3194,6 +3249,10 @@ snapshots:
wordwrap@1.0.0: {}
+ yaml@2.8.0: {}
+
yocto-queue@0.1.0: {}
zimmerframe@1.1.2: {}
+
+ zod@3.25.67: {}
diff --git a/src/commands/LogReadingFinishedCommand.ts b/src/commands/LogReadingFinishedCommand.ts
index 1607078..07162f2 100644
--- a/src/commands/LogReadingFinishedCommand.ts
+++ b/src/commands/LogReadingFinishedCommand.ts
@@ -11,6 +11,7 @@ import type { BookTrackerPluginSettings } from "@ui/settings";
import { RatingModal } from "@ui/modals";
import type { ReadingLog } from "@utils/ReadingLog";
import { READ_STATE } from "@src/const";
+import { mkdirRecursive, dirname } from "@utils/fs";
export class LogReadingFinishedCommand extends EditorCheckCommand {
constructor(
@@ -51,7 +52,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
const fileName = file.basename;
const pageCount = this.getPageCount(file);
- const rating = await RatingModal.createAndOpen(
+ const ratings = await RatingModal.createAndOpen(
this.app,
this.settings.spiceProperty !== ""
);
@@ -71,9 +72,9 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter[this.settings.statusProperty] = READ_STATE;
frontMatter[this.settings.endDateProperty] = endDate;
- frontMatter[this.settings.ratingProperty] = rating;
+ frontMatter[this.settings.ratingProperty] = ratings.rating;
if (this.settings.spiceProperty !== "") {
- frontMatter[this.settings.spiceProperty] = rating;
+ frontMatter[this.settings.spiceProperty] = ratings.spice;
}
});
@@ -82,6 +83,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
const datePath = moment().format("YYYY/MMMM");
const newPath = `${this.settings.readBooksFolder}/${datePath}/${file.name}`;
+ await mkdirRecursive(this.app.vault, dirname(newPath));
await this.app.vault.rename(file, newPath);
}
diff --git a/src/main.ts b/src/main.ts
index 1604317..b8bc086 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,7 +8,10 @@ import { Templater } from "@utils/Templater";
import { CONTENT_TYPE_EXTENSIONS } from "./const";
import { Storage } from "@utils/Storage";
import { ReadingLog } from "@utils/ReadingLog";
-import { registerReadingLogCodeBlockProcessor } from "@ui/code-blocks";
+import {
+ registerReadingLogCodeBlockProcessor,
+ registerReadingStatsCodeBlockProcessor,
+} from "@ui/code-blocks";
import type { Book } from "./types";
import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
@@ -81,6 +84,7 @@ export default class BookTrackerPlugin extends Plugin {
this.addSettingTab(new BookTrackerSettingTab(this));
registerReadingLogCodeBlockProcessor(this);
+ registerReadingStatsCodeBlockProcessor(this);
}
onunload() {}
diff --git a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte
index e8cce85..04d120f 100644
--- a/src/ui/code-blocks/ReadingLogCodeBlockView.svelte
+++ b/src/ui/code-blocks/ReadingLogCodeBlockView.svelte
@@ -18,11 +18,6 @@
return `obsidian://open?vault=${v}&file=${f}`;
}
- function formatDate(date: Date) {
- // @ts-expect-error Moment is provided by Obsidian
- return moment(date).format("YYYY-MM-DD");
- }
-
let entries = $state(
plugin.readingLog.getEntries().map((entry, id) => ({
...entry,
@@ -38,33 +33,44 @@
}
const years = $derived([
- ...new Set(entries.map((entry) => entry.createdAt.getFullYear())),
+ ...new Set(entries.map((entry) => entry.createdAt.year())),
]);
- let selectedYear = $state(new Date().getFullYear().toString());
+ // @ts-expect-error Moment is provided by Obsidian
+ let selectedYear = $state(moment().year().toString());
const filterYear = $derived(
selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10),
);
- let selectedMonth = $state(
- (new Date().getMonth() + 1).toLocaleString("en-US", {
- minimumIntegerDigits: 2,
- }),
- );
+ // @ts-expect-error Moment is provided by Obsidian
+ let selectedMonth = $state(moment().format("MM"));
const filterMonth = $derived(
selectedMonth === "" ? undefined : parseInt(selectedMonth, 10),
);
- const filteredEntries = $derived(
- filterYear === ALL_TIME
- ? entries
- : entries.filter(
- (entry) =>
- entry.createdAt.getFullYear() === filterYear &&
- (filterMonth === undefined ||
- entry.createdAt.getMonth() === filterMonth - 1),
- ),
- );
+ const filteredEntries = $derived.by(() => {
+ if (filterYear === ALL_TIME) {
+ return entries;
+ }
+
+ // @ts-expect-error Moment is provided by Obsidian
+ let startDate = moment().year(filterYear).startOf("year");
+
+ // @ts-expect-error Moment is provided by Obsidian
+ let endDate = moment().year(filterYear).endOf("year");
+
+ if (filterMonth !== undefined) {
+ startDate = startDate.month(filterMonth - 1).startOf("month");
+ endDate = endDate.month(filterMonth - 1).endOf("month");
+ }
+
+ return entries.filter((entry) => {
+ return (
+ entry.createdAt.isSameOrAfter(startDate) &&
+ entry.createdAt.isSameOrBefore(endDate)
+ );
+ });
+ });
function createEntry() {
const modal = new ReadingLogEntryEditModal(plugin, async (entry) => {
@@ -140,7 +146,7 @@
{#each filteredEntries as entry}
- {formatDate(entry.createdAt)} |
+ {entry.createdAt.format("YYYY-MM-DD")} |
{entry.book} |
@@ -163,6 +169,10 @@
+ {:else}
+
+ No entries found |
+
{/each}
diff --git a/src/ui/code-blocks/ReadingStatsCodeBlock.ts b/src/ui/code-blocks/ReadingStatsCodeBlock.ts
new file mode 100644
index 0000000..db759c6
--- /dev/null
+++ b/src/ui/code-blocks/ReadingStatsCodeBlock.ts
@@ -0,0 +1,116 @@
+import { registerCodeBlockRenderer } from ".";
+import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
+import ReadingStatsCodeBlockView from "./ReadingStatsCodeBlockView.svelte";
+import type BookTrackerPlugin from "@src/main";
+import * as z from "zod/v4";
+import { COLOR_NAMES } from "@utils/color";
+
+export function registerReadingStatsCodeBlockProcessor(
+ plugin: BookTrackerPlugin
+): void {
+ registerCodeBlockRenderer(
+ plugin,
+ "readingstats",
+ (source, el) => new ReadingStatsCodeBlockRenderer(source, el, plugin)
+ );
+}
+
+const PieGroupingSchema = z.object({
+ label: z.string(),
+ min: z.optional(z.number()),
+ max: z.optional(z.number()),
+});
+
+export type PieGrouping = z.infer;
+
+const color = z.union([z.literal("rainbow"), z.enum(COLOR_NAMES)]);
+
+const PieChartColorSchema = z.union([
+ color,
+ z.array(z.enum(COLOR_NAMES)),
+ z.array(z.object({ label: z.string(), color: z.enum(COLOR_NAMES) })),
+]);
+
+export type PieChartColor = z.infer;
+
+const PieChart = z.object({
+ type: z.literal("pie"),
+ property: z.string(),
+ unit: z.optional(z.string()),
+ unitPlural: z.optional(z.string()),
+ groups: z.optional(z.array(PieGroupingSchema)),
+ responsive: z.optional(z.boolean()),
+ color: z.optional(PieChartColorSchema),
+});
+
+const BarChart = z.object({
+ type: z.literal("bar"),
+ property: z.string(),
+ horizontal: z.optional(z.boolean()),
+ sortByLabel: z.optional(z.boolean()),
+ topN: z.optional(z.number()),
+ unit: z.optional(z.string()),
+ unitPlural: z.optional(z.string()),
+ responsive: z.optional(z.boolean()),
+ color: z.optional(color),
+});
+
+const LineChart = z.object({
+ type: z.literal("line"),
+ property: z.string(),
+ unit: z.optional(z.string()),
+ unitPlural: z.optional(z.string()),
+ secondProperty: z.optional(z.string()),
+ secondUnit: z.optional(z.string()),
+ secondUnitPlural: z.optional(z.string()),
+ responsive: z.optional(z.boolean()),
+});
+
+const BooksAndPagesChart = z.object({
+ type: z.literal("books-and-pages"),
+});
+
+export const ReadingStatsSectionSchema = z.object({
+ title: z.string(),
+ stats: z.optional(
+ z.array(
+ z.discriminatedUnion("type", [
+ z.object({
+ type: z.enum(["count", "average", "total"]),
+ label: z.string(),
+ property: z.string(),
+ }),
+ z.object({
+ type: z.literal("book-count"),
+ label: z.string(),
+ }),
+ ])
+ )
+ ),
+ charts: z.array(
+ z.discriminatedUnion("type", [
+ PieChart,
+ BarChart,
+ LineChart,
+ BooksAndPagesChart,
+ ])
+ ),
+});
+
+export type ReadingStatsSection = z.infer;
+
+export class ReadingStatsCodeBlockRenderer extends SvelteCodeBlockRenderer<
+ typeof ReadingStatsCodeBlockView
+> {
+ constructor(
+ source: string,
+ contentEl: HTMLElement,
+ plugin: BookTrackerPlugin
+ ) {
+ super(contentEl, ReadingStatsCodeBlockView, {
+ props: { plugin, source },
+ });
+ }
+
+ onunload() {}
+}
diff --git a/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte
new file mode 100644
index 0000000..031a377
--- /dev/null
+++ b/src/ui/code-blocks/ReadingStatsCodeBlockView.svelte
@@ -0,0 +1,193 @@
+
+
+
+ {#if error}
+
{@html error.replace(/\n/g, "
")}
+ {/if}
+
+
+
+ {#if metadataStore.filterYear !== ALL_TIME}
+
+ {/if}
+
+
+ {#each sections as section}
+
+
{section.title}
+
+ {#if section.stats}
+ {#each section.stats as stat}
+ {#if stat.type === "average"}
+
+ {:else if stat.type === "count"}
+
+ {:else if stat.type === "book-count"}
+
+ {:else if stat.type === "total"}
+
+ {/if}
+ {/each}
+ {/if}
+
+
+ {#each section.charts as chart}
+ {#if chart.type === "bar"}
+
+
+
+ {:else if chart.type === "pie"}
+
+ {:else if chart.type === "books-and-pages"}
+
+
+
+ {/if}
+ {/each}
+
+
+ {/each}
+
+
+
diff --git a/src/ui/code-blocks/index.ts b/src/ui/code-blocks/index.ts
index 72caada..1a7457e 100644
--- a/src/ui/code-blocks/index.ts
+++ b/src/ui/code-blocks/index.ts
@@ -44,3 +44,4 @@ export class SvelteCodeBlockRenderer<
}
export { registerReadingLogCodeBlockProcessor } from "./ReadingLogCodeBlock";
+export { registerReadingStatsCodeBlockProcessor } from "./ReadingStatsCodeBlock";
diff --git a/src/ui/components/RatingInput.svelte b/src/ui/components/RatingInput.svelte
index d2738ad..9da2bf7 100644
--- a/src/ui/components/RatingInput.svelte
+++ b/src/ui/components/RatingInput.svelte
@@ -81,7 +81,6 @@
-
@@ -122,10 +121,6 @@
.rating-input {
cursor: pointer;
- input {
- display: none;
- }
-
.ctrl {
position: absolute;
z-index: 2;
diff --git a/src/ui/components/charts/Bar.svelte b/src/ui/components/charts/Bar.svelte
new file mode 100644
index 0000000..0991be5
--- /dev/null
+++ b/src/ui/components/charts/Bar.svelte
@@ -0,0 +1,125 @@
+
+
+
diff --git a/src/ui/components/charts/BookAndPages.svelte b/src/ui/components/charts/BookAndPages.svelte
new file mode 100644
index 0000000..08fc967
--- /dev/null
+++ b/src/ui/components/charts/BookAndPages.svelte
@@ -0,0 +1,92 @@
+
+
+
diff --git a/src/ui/components/charts/Pie.svelte b/src/ui/components/charts/Pie.svelte
new file mode 100644
index 0000000..55929d8
--- /dev/null
+++ b/src/ui/components/charts/Pie.svelte
@@ -0,0 +1,121 @@
+
+
+
diff --git a/src/ui/components/stats/AverageStat.svelte b/src/ui/components/stats/AverageStat.svelte
new file mode 100644
index 0000000..1d805f2
--- /dev/null
+++ b/src/ui/components/stats/AverageStat.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/ui/components/stats/BookCountStat.svelte b/src/ui/components/stats/BookCountStat.svelte
new file mode 100644
index 0000000..dd65561
--- /dev/null
+++ b/src/ui/components/stats/BookCountStat.svelte
@@ -0,0 +1,15 @@
+
+
+
diff --git a/src/ui/components/stats/CountStat.svelte b/src/ui/components/stats/CountStat.svelte
new file mode 100644
index 0000000..fc0b9ba
--- /dev/null
+++ b/src/ui/components/stats/CountStat.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/ui/components/stats/Stat.svelte b/src/ui/components/stats/Stat.svelte
new file mode 100644
index 0000000..23ce7d8
--- /dev/null
+++ b/src/ui/components/stats/Stat.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {label}
+ {numberFormatter.format(value)}
+
+
+
diff --git a/src/ui/components/stats/TotalStat.svelte b/src/ui/components/stats/TotalStat.svelte
new file mode 100644
index 0000000..06f1476
--- /dev/null
+++ b/src/ui/components/stats/TotalStat.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/ui/directives/chart.ts b/src/ui/directives/chart.ts
new file mode 100644
index 0000000..d84ddba
--- /dev/null
+++ b/src/ui/directives/chart.ts
@@ -0,0 +1,73 @@
+import {
+ ArcElement,
+ BarController,
+ BarElement,
+ CategoryScale,
+ Chart,
+ Colors,
+ Legend,
+ LinearScale,
+ LineController,
+ LineElement,
+ PieController,
+ PointElement,
+ Tooltip,
+ type ChartConfiguration,
+ type ChartConfigurationCustomTypesPerDataset,
+ type ChartType,
+ type DefaultDataPoint,
+} from "chart.js";
+import { debounce } from "obsidian";
+
+Chart.register(
+ Colors,
+ BarController,
+ BarElement,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Tooltip,
+ PieController,
+ ArcElement,
+ LineController,
+ LineElement,
+ PointElement
+);
+
+export function chart<
+ Type extends ChartType = ChartType,
+ Data = DefaultDataPoint
,
+ Label = unknown
+>(
+ canvas: HTMLCanvasElement,
+ config:
+ | ChartConfiguration
+ | ChartConfigurationCustomTypesPerDataset
+) {
+ const chart = new Chart(canvas, config);
+
+ const resizeObserver = new ResizeObserver(
+ debounce(() => {
+ requestAnimationFrame(() => {
+ chart.resize();
+ });
+ }, 500)
+ );
+
+ resizeObserver.observe(canvas);
+
+ return {
+ update: (
+ config:
+ | ChartConfiguration
+ | ChartConfigurationCustomTypesPerDataset
+ ) => {
+ chart.data = config.data;
+ chart.update();
+ },
+ destroy: () => {
+ resizeObserver.disconnect();
+ chart.destroy();
+ },
+ };
+}
diff --git a/src/ui/settings/BookTrackerSettingTabView.svelte b/src/ui/settings/BookTrackerSettingTabView.svelte
index c7ec3ea..1b3718b 100644
--- a/src/ui/settings/BookTrackerSettingTabView.svelte
+++ b/src/ui/settings/BookTrackerSettingTabView.svelte
@@ -6,7 +6,7 @@
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 { createSettings } from "@ui/stores/settings.svelte";
import { onMount } from "svelte";
type Props = {
@@ -16,92 +16,90 @@
const { plugin }: Props = $props();
const { app } = plugin;
- const settings = createSettingsStore(plugin);
+ const settingsStore = createSettings(plugin);
- onMount(async () => {
- await settings.load();
- });
+ onMount(async () => settingsStore.load());
-
+
-
+
-
+
-
+
diff --git a/src/ui/settings/store.ts b/src/ui/settings/store.ts
deleted file mode 100644
index d551913..0000000
--- a/src/ui/settings/store.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type BookTrackerPlugin from "@src/main";
-import { writable, type Writable } from "svelte/store";
-import { type BookTrackerSettings, DEFAULT_SETTINGS } from "./types";
-
-type SettingsStore = Writable & {
- load: () => Promise;
-};
-
-export function createSettingsStore(plugin: BookTrackerPlugin): SettingsStore {
- const { subscribe, set, update } =
- writable(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,
- };
-}
diff --git a/src/ui/stores/metadata.svelte.ts b/src/ui/stores/metadata.svelte.ts
new file mode 100644
index 0000000..7dd9ea7
--- /dev/null
+++ b/src/ui/stores/metadata.svelte.ts
@@ -0,0 +1,190 @@
+import { READ_STATE } from "@src/const";
+import type { CachedMetadata, TFile } from "obsidian";
+import { getContext, setContext } from "svelte";
+import { getSettingsContext } from "./settings.svelte";
+import type BookTrackerPlugin from "@src/main";
+
+export type FileMetadata = {
+ file: TFile;
+ frontmatter: Record;
+};
+
+export type FileProperty = {
+ file: TFile;
+ value: any;
+};
+
+export const ALL_TIME = "ALL_TIME";
+
+interface MetadataStore {
+ get metadata(): FileMetadata[];
+ filterYear: number | typeof ALL_TIME;
+ filterMonth: number | typeof ALL_TIME;
+ get filterYears(): number[];
+ get filterMonths(): { label: string; value: number }[];
+
+ destroy(): void;
+}
+export function createMetadata(plugin: BookTrackerPlugin): MetadataStore {
+ const settings = getSettingsContext().settings;
+
+ const initialMetadata: FileMetadata[] = [];
+
+ for (const file of plugin.app.vault.getMarkdownFiles()) {
+ const frontmatter =
+ plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {};
+
+ if (frontmatter[settings.statusProperty] !== READ_STATE) {
+ continue;
+ }
+
+ initialMetadata.push({ file, frontmatter });
+ }
+
+ const thisYear = new Date().getFullYear();
+
+ let metadata = $state(initialMetadata);
+ let filterYear: number | typeof ALL_TIME = $state(thisYear);
+ let filterMonth: number | typeof ALL_TIME = $state(ALL_TIME);
+ const filteredMetadata = $derived.by(() => {
+ console.log("here");
+ return metadata.filter((f) => {
+ // @ts-expect-error Moment is provided by Obsidian
+ const endDate = moment(f.frontmatter[settings.endDateProperty]);
+ if (filterYear !== ALL_TIME) {
+ if (endDate.year() !== filterYear) {
+ return false;
+ }
+ }
+
+ if (filterMonth !== ALL_TIME) {
+ if (endDate.month() !== filterMonth - 1) {
+ return false;
+ }
+ }
+ return true;
+ });
+ });
+
+ const filterYears = $derived.by(() => {
+ const years = new Set();
+ for (const f of metadata) {
+ // @ts-expect-error Moment is provided by Obsidian
+ const endDate = moment(f.frontmatter[settings.endDateProperty]);
+ years.add(endDate.year());
+ }
+ return Array.from(years).sort((a, b) => a - b);
+ });
+
+ const filterMonths = $derived.by(() => {
+ if (filterYear === ALL_TIME) {
+ return [];
+ }
+
+ const months = [];
+ for (const f of metadata) {
+ // @ts-expect-error Moment is provided by Obsidian
+ const endDate = moment(f.frontmatter[settings.endDateProperty]);
+
+ if (endDate.year() !== filterYear) {
+ continue;
+ }
+
+ months.push({
+ label: endDate.format("MMMM"),
+ value: endDate.month() + 1,
+ });
+ }
+
+ return months
+ .filter(
+ (month, idx, self) =>
+ idx === self.findIndex((m) => m.value === month.value)
+ )
+ .sort((a, b) => a.value - b.value);
+ });
+
+ function onChanged(file: TFile, _data: string, cache: CachedMetadata) {
+ metadata = metadata.map((f) => {
+ if (f.file.path === file.path) {
+ return {
+ ...f,
+ frontmatter: cache.frontmatter ?? {},
+ };
+ }
+ return f;
+ });
+ }
+
+ plugin.registerEvent(plugin.app.metadataCache.on("changed", onChanged));
+
+ function onDeleted(file: TFile) {
+ metadata = metadata.filter((f) => f.file.path !== file.path);
+ }
+ plugin.registerEvent(plugin.app.metadataCache.on("deleted", onDeleted));
+
+ return {
+ get metadata() {
+ return filteredMetadata;
+ },
+ get filterYear() {
+ return filterYear;
+ },
+ set filterYear(value) {
+ filterYear = value;
+ },
+ get filterMonth() {
+ return filterMonth;
+ },
+ set filterMonth(value) {
+ filterMonth = value;
+ },
+ get filterYears() {
+ return filterYears;
+ },
+ get filterMonths() {
+ return filterMonths;
+ },
+ destroy() {
+ plugin.app.metadataCache.off("changed", onChanged);
+ plugin.app.metadataCache.off("deleted", onDeleted);
+ },
+ };
+}
+
+const METADATA_KEY = Symbol("metadata");
+
+export function setMetadataContext(state: MetadataStore) {
+ setContext(METADATA_KEY, state);
+}
+
+export function getMetadataContext(): MetadataStore {
+ return getContext(METADATA_KEY) as MetadataStore;
+}
+
+function notEmpty(value: any): boolean {
+ return value !== undefined && value !== null && value !== "" && value !== 0;
+}
+
+interface PropertyStore {
+ get propertyData(): FileProperty[];
+}
+
+export function createPropertyStore(
+ property: string,
+ filter: (value: any) => boolean = notEmpty
+): PropertyStore {
+ const store = getMetadataContext();
+
+ const propertyData = $derived(
+ store.metadata
+ .map((f) => ({ ...f, value: f.frontmatter[property] }))
+ .filter((f) => (filter ? filter(f.value) : true))
+ );
+
+ return {
+ get propertyData() {
+ return propertyData;
+ },
+ };
+}
diff --git a/src/ui/stores/settings.svelte.ts b/src/ui/stores/settings.svelte.ts
new file mode 100644
index 0000000..102adfd
--- /dev/null
+++ b/src/ui/stores/settings.svelte.ts
@@ -0,0 +1,45 @@
+import type BookTrackerPlugin from "@src/main";
+import { type BookTrackerSettings, DEFAULT_SETTINGS } from "../settings/types";
+import { getContext, setContext } from "svelte";
+
+interface SettingsStore {
+ settings: BookTrackerSettings;
+ load(): Promise;
+}
+
+export function createSettings(plugin: BookTrackerPlugin) {
+ let settings = $state(plugin.settings);
+
+ $effect(() => {
+ if (settings === DEFAULT_SETTINGS) {
+ return;
+ }
+
+ plugin.settings = settings;
+ plugin.saveSettings();
+ });
+
+ return {
+ get settings() {
+ return settings;
+ },
+ async load() {
+ const newSettings = await plugin.loadData();
+
+ settings = {
+ ...settings,
+ ...newSettings,
+ };
+ },
+ };
+}
+
+const SETTINGS_KEY = Symbol("settings");
+
+export function setSettingsContext(state: SettingsStore) {
+ setContext(SETTINGS_KEY, state);
+}
+
+export function getSettingsContext(): SettingsStore {
+ return getContext(SETTINGS_KEY) as SettingsStore;
+}
diff --git a/src/utils/ReadingLog.ts b/src/utils/ReadingLog.ts
index e1dbcaf..ec806d4 100644
--- a/src/utils/ReadingLog.ts
+++ b/src/utils/ReadingLog.ts
@@ -1,19 +1,12 @@
import type { Storage } from "./Storage";
+import type { Moment } from "moment";
export interface ReadingLogEntry {
book: string;
pagesRead: number;
pagesReadTotal: number;
pagesRemaining: number;
- createdAt: Date;
-}
-
-function isSameDay(a: Date, b: Date): boolean {
- return (
- a.getFullYear() === b.getFullYear() &&
- a.getMonth() === b.getMonth() &&
- a.getDate() === b.getDate()
- );
+ createdAt: Moment;
}
export class ReadingLog {
@@ -32,20 +25,28 @@ export class ReadingLog {
if (entries) {
this.entries = entries.map((entry) => ({
...entry,
- createdAt: new Date(entry.createdAt),
+ // @ts-expect-error Moment is provided by Obsidian
+ createdAt: moment(entry.createdAt),
}));
}
}
private sortEntries() {
- this.entries = this.entries.sort(
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
+ this.entries = this.entries.sort((a, b) =>
+ a.createdAt.diff(b.createdAt)
);
}
async save(filename = "reading-log.json") {
this.sortEntries();
- await this.storage.writeJSON(filename, this.entries);
+ await this.storage.writeJSON(
+ filename,
+ this.entries.map((entry) => ({
+ ...entry,
+ // @ts-expect-error Moment is provided by Obsidian
+ createdAt: moment(entry.createdAt).toISOString(true),
+ }))
+ );
}
public getEntries(): ReadingLogEntry[] {
@@ -82,10 +83,14 @@ export class ReadingLog {
: pageEnded,
pagesReadTotal: pageEnded,
pagesRemaining: pageCount - pageEnded,
- createdAt: new Date(),
+ // @ts-expect-error Moment is provided by Obsidian
+ createdAt: moment(),
};
- if (lastEntry && isSameDay(lastEntry.createdAt, newEntry.createdAt)) {
+ if (
+ lastEntry &&
+ lastEntry.createdAt.isSame(newEntry.createdAt, "day")
+ ) {
newEntry.pagesRead += lastEntry.pagesRead;
await this.updateEntry(this.entries.indexOf(lastEntry), newEntry);
} else {
diff --git a/src/utils/color.ts b/src/utils/color.ts
new file mode 100644
index 0000000..a76737c
--- /dev/null
+++ b/src/utils/color.ts
@@ -0,0 +1,74 @@
+import chroma from "chroma-js";
+
+const style = getComputedStyle(document.body);
+
+export const COLOR_NAMES = [
+ "red",
+ "orange",
+ "yellow",
+ "green",
+ "cyan",
+ "blue",
+ "purple",
+ "pink",
+] as const;
+
+export type ColorName = (typeof COLOR_NAMES)[number];
+export type RGB = [number, number, number];
+
+export class Color {
+ constructor(
+ private readonly r: number,
+ private readonly g: number,
+ private readonly b: number,
+ private readonly a: number = 1
+ ) {}
+
+ get hex(): string {
+ const rHex = this.r.toString(16).padStart(2, "0");
+ const gHex = this.g.toString(16).padStart(2, "0");
+ const bHex = this.b.toString(16).padStart(2, "0");
+
+ return `#${rHex}${gHex}${bHex}`;
+ }
+
+ get rgb(): string {
+ return `rgb(${this.r}, ${this.g}, ${this.b})`;
+ }
+
+ get rgba(): string {
+ return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`;
+ }
+
+ alpha(alpha: number): Color {
+ return new Color(this.r, this.g, this.b, alpha);
+ }
+
+ static fromName(color: ColorName): Color {
+ const rawRgb = style
+ .getPropertyValue(`--color-${color}-rgb`)
+ .split(", ");
+
+ return new Color(
+ parseInt(rawRgb[0], 10),
+ parseInt(rawRgb[1], 10),
+ parseInt(rawRgb[2], 10)
+ );
+ }
+
+ static getAll(): Color[] {
+ return COLOR_NAMES.map((color) => Color.fromName(color));
+ }
+
+ static scale(n: number): Color[] {
+ const colors = COLOR_NAMES.map((color) =>
+ style.getPropertyValue(`--color-${color}`)
+ );
+
+ return chroma
+ .scale(colors)
+ .mode("lch")
+ .colors(n, "rgb")
+ .map(([r, g, b]) => new Color(r, g, b));
+ }
+}
diff --git a/src/utils/frequencyArray.ts b/src/utils/frequencyArray.ts
new file mode 100644
index 0000000..6e878b0
--- /dev/null
+++ b/src/utils/frequencyArray.ts
@@ -0,0 +1,15 @@
+export type FrequencyArray = { value: T; count: number }[];
+
+export function frequencyArray(arr: T[]): FrequencyArray {
+ const map = new Map();
+
+ for (const item of arr) {
+ const count = map.get(item) ?? 0;
+ map.set(item, count + 1);
+ }
+
+ return Array.from(map.entries()).map(([value, count]) => ({
+ value,
+ count,
+ }));
+}
diff --git a/src/utils/fs.ts b/src/utils/fs.ts
new file mode 100644
index 0000000..d5b0db5
--- /dev/null
+++ b/src/utils/fs.ts
@@ -0,0 +1,54 @@
+import { normalizePath, type Vault } from "obsidian";
+
+/**
+ * A simple analog of Node.js's `path.join(...)`.
+ * See: https://gist.github.com/creationix/7435851#gistcomment-3698888
+ */
+export default function joinPath(...segments: string[]): string {
+ const parts = segments.reduce((parts, segment) => {
+ // Remove leading slashes from non-first part.
+ if (parts.length > 0) {
+ segment = segment.replace(/^\//, "");
+ }
+ // Remove trailing slashes.
+ segment = segment.replace(/\/$/, "");
+ return parts.concat(segment.split("/"));
+ }, [] as string[]);
+ const resultParts: string[] = [];
+ for (const part of parts) {
+ if (part === ".") {
+ continue;
+ }
+ if (part === "..") {
+ resultParts.pop();
+ continue;
+ }
+ resultParts.push(part);
+ }
+ return resultParts.join("/");
+}
+
+export function dirname(path: string): string {
+ return joinPath(path, "..");
+}
+
+export async function mkdirRecursive(
+ vault: Vault,
+ path: string
+): Promise {
+ const stack: string[] = [];
+ let currentPath = normalizePath(path);
+
+ if (await vault.adapter.exists(currentPath)) {
+ return;
+ }
+
+ while (!(await vault.adapter.exists(currentPath))) {
+ stack.push(currentPath);
+ currentPath = dirname(currentPath);
+ }
+
+ while (stack.length > 0) {
+ await vault.adapter.mkdir(stack.pop()!);
+ }
+}
diff --git a/src/utils/groupBy.ts b/src/utils/groupBy.ts
new file mode 100644
index 0000000..949bcac
--- /dev/null
+++ b/src/utils/groupBy.ts
@@ -0,0 +1,8 @@
+export function groupBy(array: T[], f: (item: T) => string) {
+ return array.reduce((groups, item) => {
+ const group = f(item);
+ groups[group] = groups[group] || [];
+ groups[group].push(item);
+ return groups;
+ }, {} as Record);
+}