generated from tpl/obsidian-sample-plugin
Add reading stats code block
This commit is contained in:
parent
8c94599a31
commit
eada608003
|
@ -54,7 +54,11 @@ const context = await esbuild.context({
|
||||||
plugins: [
|
plugins: [
|
||||||
esbuildSvelte({
|
esbuildSvelte({
|
||||||
preprocess: sveltePreprocess(),
|
preprocess: sveltePreprocess(),
|
||||||
compilerOptions: { dev: !prod },
|
compilerOptions: {
|
||||||
|
dev: !prod,
|
||||||
|
warningFilter: (warning) =>
|
||||||
|
!warning.filename?.includes("node_modules"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "copy-plugin",
|
name: "copy-plugin",
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.30.0",
|
"@eslint/js": "^9.30.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@types/chroma-js": "^3.1.1",
|
||||||
"@types/node": "^24.0.6",
|
"@types/node": "^24.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"@typescript-eslint/parser": "5.29.0",
|
||||||
|
@ -29,6 +30,7 @@
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"lucide-svelte": "^0.525.0",
|
"lucide-svelte": "^0.525.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
"runed": "^0.29.1",
|
"runed": "^0.29.1",
|
||||||
|
@ -39,5 +41,11 @@
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.0",
|
||||||
"typescript": "5.0.4"
|
"typescript": "5.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"chroma-js": "^3.1.2",
|
||||||
|
"yaml": "^2.8.0",
|
||||||
|
"zod": "^3.25.67"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,19 @@ settings:
|
||||||
importers:
|
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:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
|
@ -17,6 +30,9 @@ importers:
|
||||||
'@popperjs/core':
|
'@popperjs/core':
|
||||||
specifier: ^2.11.8
|
specifier: ^2.11.8
|
||||||
version: 2.11.8
|
version: 2.11.8
|
||||||
|
'@types/chroma-js':
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.6
|
specifier: ^24.0.6
|
||||||
version: 24.0.6
|
version: 24.0.6
|
||||||
|
@ -53,6 +69,9 @@ importers:
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.525.0
|
specifier: ^0.525.0
|
||||||
version: 0.525.0(svelte@5.34.8)
|
version: 0.525.0(svelte@5.34.8)
|
||||||
|
moment:
|
||||||
|
specifier: ^2.30.1
|
||||||
|
version: 2.30.1
|
||||||
npm-run-all:
|
npm-run-all:
|
||||||
specifier: ^4.1.5
|
specifier: ^4.1.5
|
||||||
version: 4.1.5
|
version: 4.1.5
|
||||||
|
@ -308,6 +327,9 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
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':
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
|
|
||||||
|
@ -413,6 +435,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
acorn: ^8.9.0
|
acorn: ^8.9.0
|
||||||
|
|
||||||
|
'@types/chroma-js@3.1.1':
|
||||||
|
resolution: {integrity: sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==}
|
||||||
|
|
||||||
'@types/codemirror@5.60.8':
|
'@types/codemirror@5.60.8':
|
||||||
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
|
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
|
||||||
|
|
||||||
|
@ -576,10 +601,17 @@ packages:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
chart.js@4.5.0:
|
||||||
|
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
||||||
|
chroma-js@3.1.2:
|
||||||
|
resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -1124,6 +1156,9 @@ packages:
|
||||||
moment@2.29.4:
|
moment@2.29.4:
|
||||||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||||
|
|
||||||
|
moment@2.30.1:
|
||||||
|
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||||
|
|
||||||
mri@1.2.0:
|
mri@1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -1595,6 +1630,11 @@ packages:
|
||||||
wordwrap@1.0.0:
|
wordwrap@1.0.0:
|
||||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
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:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -1602,6 +1642,9 @@ packages:
|
||||||
zimmerframe@1.1.2:
|
zimmerframe@1.1.2:
|
||||||
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
||||||
|
|
||||||
|
zod@3.25.67:
|
||||||
|
resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
|
@ -1764,6 +1807,8 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4': {}
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2': {}
|
'@marijn/find-cluster-break@1.0.2': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
@ -1845,6 +1890,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|
||||||
|
'@types/chroma-js@3.1.1': {}
|
||||||
|
|
||||||
'@types/codemirror@5.60.8':
|
'@types/codemirror@5.60.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/tern': 0.23.9
|
'@types/tern': 0.23.9
|
||||||
|
@ -2034,10 +2081,16 @@ snapshots:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
chart.js@4.5.0:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.4
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
|
|
||||||
|
chroma-js@3.1.2: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@1.9.3:
|
color-convert@1.9.3:
|
||||||
|
@ -2678,6 +2731,8 @@ snapshots:
|
||||||
|
|
||||||
moment@2.29.4: {}
|
moment@2.29.4: {}
|
||||||
|
|
||||||
|
moment@2.30.1: {}
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
@ -3194,6 +3249,10 @@ snapshots:
|
||||||
|
|
||||||
wordwrap@1.0.0: {}
|
wordwrap@1.0.0: {}
|
||||||
|
|
||||||
|
yaml@2.8.0: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zimmerframe@1.1.2: {}
|
zimmerframe@1.1.2: {}
|
||||||
|
|
||||||
|
zod@3.25.67: {}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type { BookTrackerPluginSettings } from "@ui/settings";
|
||||||
import { RatingModal } from "@ui/modals";
|
import { RatingModal } from "@ui/modals";
|
||||||
import type { ReadingLog } from "@utils/ReadingLog";
|
import type { ReadingLog } from "@utils/ReadingLog";
|
||||||
import { READ_STATE } from "@src/const";
|
import { READ_STATE } from "@src/const";
|
||||||
|
import { mkdirRecursive, dirname } from "@utils/fs";
|
||||||
|
|
||||||
export class LogReadingFinishedCommand extends EditorCheckCommand {
|
export class LogReadingFinishedCommand extends EditorCheckCommand {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -51,7 +52,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
|
||||||
const fileName = file.basename;
|
const fileName = file.basename;
|
||||||
const pageCount = this.getPageCount(file);
|
const pageCount = this.getPageCount(file);
|
||||||
|
|
||||||
const rating = await RatingModal.createAndOpen(
|
const ratings = await RatingModal.createAndOpen(
|
||||||
this.app,
|
this.app,
|
||||||
this.settings.spiceProperty !== ""
|
this.settings.spiceProperty !== ""
|
||||||
);
|
);
|
||||||
|
@ -71,9 +72,9 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
|
||||||
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
|
this.app.fileManager.processFrontMatter(file, (frontMatter) => {
|
||||||
frontMatter[this.settings.statusProperty] = READ_STATE;
|
frontMatter[this.settings.statusProperty] = READ_STATE;
|
||||||
frontMatter[this.settings.endDateProperty] = endDate;
|
frontMatter[this.settings.endDateProperty] = endDate;
|
||||||
frontMatter[this.settings.ratingProperty] = rating;
|
frontMatter[this.settings.ratingProperty] = ratings.rating;
|
||||||
if (this.settings.spiceProperty !== "") {
|
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 datePath = moment().format("YYYY/MMMM");
|
||||||
const newPath = `${this.settings.readBooksFolder}/${datePath}/${file.name}`;
|
const newPath = `${this.settings.readBooksFolder}/${datePath}/${file.name}`;
|
||||||
|
|
||||||
|
await mkdirRecursive(this.app.vault, dirname(newPath));
|
||||||
await this.app.vault.rename(file, newPath);
|
await this.app.vault.rename(file, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,10 @@ import { Templater } from "@utils/Templater";
|
||||||
import { CONTENT_TYPE_EXTENSIONS } from "./const";
|
import { CONTENT_TYPE_EXTENSIONS } from "./const";
|
||||||
import { Storage } from "@utils/Storage";
|
import { Storage } from "@utils/Storage";
|
||||||
import { ReadingLog } from "@utils/ReadingLog";
|
import { ReadingLog } from "@utils/ReadingLog";
|
||||||
import { registerReadingLogCodeBlockProcessor } from "@ui/code-blocks";
|
import {
|
||||||
|
registerReadingLogCodeBlockProcessor,
|
||||||
|
registerReadingStatsCodeBlockProcessor,
|
||||||
|
} from "@ui/code-blocks";
|
||||||
import type { Book } from "./types";
|
import type { Book } from "./types";
|
||||||
import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
|
import { SearchGoodreadsCommand } from "@commands/SearchGoodreadsCommand";
|
||||||
import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
|
import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
|
||||||
|
@ -81,6 +84,7 @@ export default class BookTrackerPlugin extends Plugin {
|
||||||
this.addSettingTab(new BookTrackerSettingTab(this));
|
this.addSettingTab(new BookTrackerSettingTab(this));
|
||||||
|
|
||||||
registerReadingLogCodeBlockProcessor(this);
|
registerReadingLogCodeBlockProcessor(this);
|
||||||
|
registerReadingStatsCodeBlockProcessor(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {}
|
onunload() {}
|
||||||
|
|
|
@ -18,11 +18,6 @@
|
||||||
return `obsidian://open?vault=${v}&file=${f}`;
|
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(
|
let entries = $state(
|
||||||
plugin.readingLog.getEntries().map((entry, id) => ({
|
plugin.readingLog.getEntries().map((entry, id) => ({
|
||||||
...entry,
|
...entry,
|
||||||
|
@ -38,33 +33,44 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const years = $derived([
|
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(
|
const filterYear = $derived(
|
||||||
selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10),
|
selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
let selectedMonth = $state(
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
(new Date().getMonth() + 1).toLocaleString("en-US", {
|
let selectedMonth = $state(moment().format("MM"));
|
||||||
minimumIntegerDigits: 2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const filterMonth = $derived(
|
const filterMonth = $derived(
|
||||||
selectedMonth === "" ? undefined : parseInt(selectedMonth, 10),
|
selectedMonth === "" ? undefined : parseInt(selectedMonth, 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredEntries = $derived(
|
const filteredEntries = $derived.by(() => {
|
||||||
filterYear === ALL_TIME
|
if (filterYear === ALL_TIME) {
|
||||||
? entries
|
return entries;
|
||||||
: entries.filter(
|
}
|
||||||
(entry) =>
|
|
||||||
entry.createdAt.getFullYear() === filterYear &&
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
(filterMonth === undefined ||
|
let startDate = moment().year(filterYear).startOf("year");
|
||||||
entry.createdAt.getMonth() === filterMonth - 1),
|
|
||||||
),
|
// @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() {
|
function createEntry() {
|
||||||
const modal = new ReadingLogEntryEditModal(plugin, async (entry) => {
|
const modal = new ReadingLogEntryEditModal(plugin, async (entry) => {
|
||||||
|
@ -140,7 +146,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filteredEntries as entry}
|
{#each filteredEntries as entry}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="date">{formatDate(entry.createdAt)}</td>
|
<td class="date">{entry.createdAt.format("YYYY-MM-DD")}</td>
|
||||||
<td class="book"
|
<td class="book"
|
||||||
><a href={bookUri(entry.book)}>{entry.book}</a></td
|
><a href={bookUri(entry.book)}>{entry.book}</a></td
|
||||||
>
|
>
|
||||||
|
@ -163,6 +169,10 @@
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">No entries found</td>
|
||||||
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -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<typeof PieGroupingSchema>;
|
||||||
|
|
||||||
|
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<typeof PieChartColorSchema>;
|
||||||
|
|
||||||
|
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<typeof ReadingStatsSectionSchema>;
|
||||||
|
|
||||||
|
export class ReadingStatsCodeBlockRenderer extends SvelteCodeBlockRenderer<
|
||||||
|
typeof ReadingStatsCodeBlockView
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
source: string,
|
||||||
|
contentEl: HTMLElement,
|
||||||
|
plugin: BookTrackerPlugin
|
||||||
|
) {
|
||||||
|
super(contentEl, ReadingStatsCodeBlockView, {
|
||||||
|
props: { plugin, source },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {}
|
||||||
|
}
|
|
@ -0,0 +1,193 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import AverageStat from "@ui/components/stats/AverageStat.svelte";
|
||||||
|
import {
|
||||||
|
ReadingStatsSectionSchema,
|
||||||
|
type ReadingStatsSection,
|
||||||
|
} from "./ReadingStatsCodeBlock";
|
||||||
|
import CountStat from "@ui/components/stats/CountStat.svelte";
|
||||||
|
import TotalStat from "@ui/components/stats/TotalStat.svelte";
|
||||||
|
import {
|
||||||
|
ALL_TIME,
|
||||||
|
createMetadata,
|
||||||
|
setMetadataContext,
|
||||||
|
} from "@ui/stores/metadata.svelte";
|
||||||
|
import { parseAllDocuments } from "yaml";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
import Bar from "@ui/components/charts/Bar.svelte";
|
||||||
|
import Pie from "@ui/components/charts/Pie.svelte";
|
||||||
|
import BookAndPages from "@ui/components/charts/BookAndPages.svelte";
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import {
|
||||||
|
createSettings,
|
||||||
|
setSettingsContext,
|
||||||
|
} from "@ui/stores/settings.svelte";
|
||||||
|
import type BookTrackerPlugin from "@src/main";
|
||||||
|
import BookCountStat from "@ui/components/stats/BookCountStat.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plugin: BookTrackerPlugin;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { plugin, source }: Props = $props();
|
||||||
|
|
||||||
|
let settingsStore = createSettings(plugin);
|
||||||
|
setSettingsContext(settingsStore);
|
||||||
|
|
||||||
|
let metadataStore = createMetadata(plugin);
|
||||||
|
setMetadataContext(metadataStore);
|
||||||
|
|
||||||
|
let sections = $state<ReadingStatsSection[]>([]);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
try {
|
||||||
|
sections = parseAllDocuments(source)
|
||||||
|
.map((doc, i) => {
|
||||||
|
const jsDoc = doc.toJS();
|
||||||
|
if (jsDoc === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ReadingStatsSectionSchema.parse(doc.toJS());
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Error parsing section #${i + 1}:\n${z.prettifyError(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((s) => s !== null);
|
||||||
|
} catch (e) {
|
||||||
|
sections = [];
|
||||||
|
if (e instanceof Error) {
|
||||||
|
error = e.message;
|
||||||
|
} else {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => metadataStore.destroy());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="obt-reading-stats">
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{@html error.replace(/\n/g, "<br>")}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="filter">
|
||||||
|
<select bind:value={metadataStore.filterYear}>
|
||||||
|
{#each metadataStore.filterYears as year}
|
||||||
|
<option value={year}>{year}</option>
|
||||||
|
{/each}
|
||||||
|
<option value={ALL_TIME}>All Time</option>
|
||||||
|
</select>
|
||||||
|
{#if metadataStore.filterYear !== ALL_TIME}
|
||||||
|
<select bind:value={metadataStore.filterMonth}>
|
||||||
|
<option value={ALL_TIME}>Select Month</option>
|
||||||
|
{#each metadataStore.filterMonths as month}
|
||||||
|
<option value={month.value}>{month.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each sections as section}
|
||||||
|
<div class="section">
|
||||||
|
<h2>{section.title}</h2>
|
||||||
|
<div class="stats">
|
||||||
|
{#if section.stats}
|
||||||
|
{#each section.stats as stat}
|
||||||
|
{#if stat.type === "average"}
|
||||||
|
<AverageStat
|
||||||
|
label={stat.label}
|
||||||
|
property={stat.property}
|
||||||
|
/>
|
||||||
|
{:else if stat.type === "count"}
|
||||||
|
<CountStat
|
||||||
|
label={stat.label}
|
||||||
|
property={stat.property}
|
||||||
|
/>
|
||||||
|
{:else if stat.type === "book-count"}
|
||||||
|
<BookCountStat label={stat.label} />
|
||||||
|
{:else if stat.type === "total"}
|
||||||
|
<TotalStat
|
||||||
|
label={stat.label}
|
||||||
|
property={stat.property}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="charts">
|
||||||
|
{#each section.charts as chart}
|
||||||
|
{#if chart.type === "bar"}
|
||||||
|
<div
|
||||||
|
class="chart bar"
|
||||||
|
class:horizontal={chart.horizontal}
|
||||||
|
class:responsive={chart.responsive}
|
||||||
|
>
|
||||||
|
<Bar {...chart} />
|
||||||
|
</div>
|
||||||
|
{:else if chart.type === "pie"}
|
||||||
|
<div
|
||||||
|
class="chart pie"
|
||||||
|
class:responsive={chart.responsive}
|
||||||
|
>
|
||||||
|
<Pie {...chart} />
|
||||||
|
</div>
|
||||||
|
{:else if chart.type === "books-and-pages"}
|
||||||
|
<div class="chart book-and-pages">
|
||||||
|
<BookAndPages />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.obt-reading-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 50rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
&.pie {
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bar.responsive {
|
||||||
|
height: 35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -44,3 +44,4 @@ export class SvelteCodeBlockRenderer<
|
||||||
}
|
}
|
||||||
|
|
||||||
export { registerReadingLogCodeBlockProcessor } from "./ReadingLogCodeBlock";
|
export { registerReadingLogCodeBlockProcessor } from "./ReadingLogCodeBlock";
|
||||||
|
export { registerReadingStatsCodeBlockProcessor } from "./ReadingStatsCodeBlock";
|
||||||
|
|
|
@ -81,7 +81,6 @@
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
<div class="rating-input" {onclick} {onmouseout}>
|
<div class="rating-input" {onclick} {onmouseout}>
|
||||||
<input type="number" {name} bind:value />
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="ctrl" {onmousemove} bind:this={ctrl}></div>
|
<div class="ctrl" {onmousemove} bind:this={ctrl}></div>
|
||||||
<div class="cont m-1" bind:clientWidth={w} bind:clientHeight={h}>
|
<div class="cont m-1" bind:clientWidth={w} bind:clientHeight={h}>
|
||||||
|
@ -122,10 +121,6 @@
|
||||||
.rating-input {
|
.rating-input {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctrl {
|
.ctrl {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
<script lang="ts">
|
||||||
|
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;
|
||||||
|
horizontal?: boolean;
|
||||||
|
sortByLabel?: boolean;
|
||||||
|
topN?: number;
|
||||||
|
unit?: string;
|
||||||
|
unitPlural?: string;
|
||||||
|
responsive?: boolean;
|
||||||
|
color?: "rainbow" | ColorName;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
property,
|
||||||
|
horizontal,
|
||||||
|
sortByLabel = false,
|
||||||
|
topN,
|
||||||
|
unit,
|
||||||
|
unitPlural,
|
||||||
|
responsive,
|
||||||
|
color,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const store = createPropertyStore(property);
|
||||||
|
|
||||||
|
function makeLabel(value: any) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
let label = value.toString();
|
||||||
|
|
||||||
|
if (unitPlural && value !== 1) {
|
||||||
|
label += ` ${unitPlural}`;
|
||||||
|
} else if (unit) {
|
||||||
|
label += ` ${unit}`;
|
||||||
|
if (value !== 1) {
|
||||||
|
label += "s";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = $derived.by(() => {
|
||||||
|
const map = new Map<any, number>();
|
||||||
|
for (const { value } of store.propertyData) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
const count = map.get(v) ?? 0;
|
||||||
|
map.set(v, count + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const count = map.get(value) ?? 0;
|
||||||
|
map.set(value, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pairs = Array.from(map.entries());
|
||||||
|
pairs.sort((a, b) => {
|
||||||
|
let index = sortByLabel ? 0 : 1;
|
||||||
|
|
||||||
|
if (horizontal) {
|
||||||
|
return b[index] - a[index];
|
||||||
|
} else {
|
||||||
|
return a[index] - b[index];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (topN) {
|
||||||
|
pairs = pairs.slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let borderColor: string[] | string | undefined =
|
||||||
|
Color.fromName("blue").hex;
|
||||||
|
let backgroundColor: string[] | string | undefined =
|
||||||
|
Color.fromName("blue").alpha(0.5).rgba;
|
||||||
|
if (color) {
|
||||||
|
if (color === "rainbow") {
|
||||||
|
borderColor = Color.scale(pairs.length).map((c) => c.hex);
|
||||||
|
backgroundColor = Color.scale(pairs.length).map(
|
||||||
|
(c) => c.alpha(0.5).rgba,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
borderColor = Color.fromName(color).hex;
|
||||||
|
backgroundColor = Color.fromName(color).alpha(0.5).rgba;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: pairs.map((p) => makeLabel(p[0])),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: pairs.map((p) => p[1]),
|
||||||
|
backgroundColor,
|
||||||
|
borderColor,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: horizontal ? "y" : "x",
|
||||||
|
responsive,
|
||||||
|
maintainAspectRatio: !responsive,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
yAlign: horizontal ? "center" : "bottom",
|
||||||
|
xAlign: horizontal ? "left" : "center",
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ChartConfiguration;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas use:chart={config}></canvas>
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { chart } from "@ui/directives/chart";
|
||||||
|
import { ALL_TIME, getMetadataContext } from "@ui/stores/metadata.svelte";
|
||||||
|
import { getSettingsContext } from "@ui/stores/settings.svelte";
|
||||||
|
import { Color } from "@utils/color";
|
||||||
|
import type { ChartConfiguration } from "chart.js";
|
||||||
|
|
||||||
|
const settings = getSettingsContext().settings;
|
||||||
|
const store = getMetadataContext();
|
||||||
|
const config = $derived.by(() => {
|
||||||
|
const items = store.metadata.map((f) => ({
|
||||||
|
pageCount: f.frontmatter[settings.pageCountProperty],
|
||||||
|
date: f.frontmatter[settings.endDateProperty],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const books = new Map<number, number>();
|
||||||
|
const pages = new Map<number, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
|
const date = moment(item.date);
|
||||||
|
let key: number;
|
||||||
|
if (store.filterYear === ALL_TIME) {
|
||||||
|
key = date.year();
|
||||||
|
} else {
|
||||||
|
key = date.month();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageCount = pages.get(key) ?? 0;
|
||||||
|
pages.set(key, pageCount + item.pageCount);
|
||||||
|
|
||||||
|
const bookCount = books.get(key) ?? 0;
|
||||||
|
books.set(key, bookCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = Array.from(books.keys())
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((m) =>
|
||||||
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
|
moment().month(m).format("MMM"),
|
||||||
|
);
|
||||||
|
const sortedBooks = Array.from(books.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map((b) => b[1]);
|
||||||
|
const sortedPages = Array.from(pages.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map((p) => p[1]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Books",
|
||||||
|
data: sortedBooks,
|
||||||
|
borderColor: Color.fromName("red").hex,
|
||||||
|
backgroundColor: Color.fromName("red").alpha(0.5).rgba,
|
||||||
|
yAxisID: "y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pages",
|
||||||
|
data: sortedPages,
|
||||||
|
borderColor: Color.fromName("blue").hex,
|
||||||
|
backgroundColor: Color.fromName("blue").alpha(0.5).rgba,
|
||||||
|
yAxisID: "y1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: "linear",
|
||||||
|
display: true,
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: "linear",
|
||||||
|
display: true,
|
||||||
|
position: "right",
|
||||||
|
|
||||||
|
// grid line settings
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ChartConfiguration;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas use:chart={config}></canvas>
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
PieChartColor,
|
||||||
|
PieGrouping,
|
||||||
|
} from "@ui/code-blocks/ReadingStatsCodeBlock";
|
||||||
|
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;
|
||||||
|
groups?: PieGrouping[];
|
||||||
|
unit?: string;
|
||||||
|
unitPlural?: string;
|
||||||
|
responsive?: boolean;
|
||||||
|
color?: PieChartColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { property, groups, unit, unitPlural, responsive, color }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const store = createPropertyStore(property);
|
||||||
|
|
||||||
|
function makeLabel(value: any) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
let label = value.toString();
|
||||||
|
|
||||||
|
if (unitPlural && value !== 1) {
|
||||||
|
label += ` ${unitPlural}`;
|
||||||
|
} else if (unit) {
|
||||||
|
label += ` ${unit}`;
|
||||||
|
if (value !== 1) {
|
||||||
|
label += "s";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = $derived.by(() => {
|
||||||
|
const map = new Map<any, number>();
|
||||||
|
|
||||||
|
if (groups) {
|
||||||
|
for (const group of groups) {
|
||||||
|
map.set(group.label, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { value } of store.propertyData) {
|
||||||
|
if (groups) {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.min && value < group.min) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (group.max && value > group.max) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = map.get(group.label) ?? 0;
|
||||||
|
map.set(group.label, count + 1);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
const count = map.get(v) ?? 0;
|
||||||
|
map.set(v, count + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const count = map.get(value) ?? 0;
|
||||||
|
map.set(value, count + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = Array.from(map.keys()).map((p) => makeLabel(p));
|
||||||
|
const data = Array.from(map.values());
|
||||||
|
|
||||||
|
let backgroundColor: string[] | string | undefined = Color.scale(
|
||||||
|
data.length,
|
||||||
|
).map((c) => c.hex);
|
||||||
|
if (color) {
|
||||||
|
if (color === "rainbow") {
|
||||||
|
backgroundColor = Color.scale(data.length).map((c) => c.hex);
|
||||||
|
} else if (typeof color === "string") {
|
||||||
|
backgroundColor = Color.fromName(color).hex;
|
||||||
|
} else if (Array.isArray(color)) {
|
||||||
|
if (color.length < data.length) {
|
||||||
|
throw new Error(
|
||||||
|
"Color array must be at least as long as the data array",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.every((c) => typeof c === "string")) {
|
||||||
|
backgroundColor = color;
|
||||||
|
} else {
|
||||||
|
const map = color.reduce(
|
||||||
|
(acc, c) => {
|
||||||
|
acc[c.label] = c.color;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, ColorName>,
|
||||||
|
);
|
||||||
|
|
||||||
|
backgroundColor = labels.map(
|
||||||
|
(label) => Color.fromName(map[label]).hex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "pie",
|
||||||
|
data: { labels, datasets: [{ data, backgroundColor }] },
|
||||||
|
options: { responsive },
|
||||||
|
} as ChartConfiguration;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas use:chart={config}></canvas>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createPropertyStore } from "@ui/stores/metadata.svelte";
|
||||||
|
import Stat from "./Stat.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
property: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, property }: Props = $props();
|
||||||
|
|
||||||
|
const store = createPropertyStore(property);
|
||||||
|
const avg = $derived.by(() => {
|
||||||
|
if (store.propertyData.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = store.propertyData.reduce((acc, f) => acc + f.value, 0);
|
||||||
|
return sum / store.propertyData.length;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Stat {label} value={avg} />
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getMetadataContext } from "@ui/stores/metadata.svelte";
|
||||||
|
import Stat from "./Stat.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label }: Props = $props();
|
||||||
|
|
||||||
|
const store = getMetadataContext();
|
||||||
|
const count = $derived(store.metadata.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Stat {label} value={count} />
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createPropertyStore } from "@ui/stores/metadata.svelte";
|
||||||
|
import Stat from "./Stat.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
property: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, property }: Props = $props();
|
||||||
|
|
||||||
|
const store = createPropertyStore(property);
|
||||||
|
const count = $derived(store.propertyData.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Stat {label} value={count} />
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Readable } from "svelte/store";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, value }: Props = $props();
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="stat">
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
<span class="value">{numberFormatter.format(value)}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
.label {
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createPropertyStore } from "@ui/stores/metadata.svelte";
|
||||||
|
import Stat from "./Stat.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
property: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, property }: Props = $props();
|
||||||
|
|
||||||
|
const store = createPropertyStore(property);
|
||||||
|
const total = $derived(
|
||||||
|
store.propertyData.reduce((acc, f) => acc + f.value, 0),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Stat {label} value={total} />
|
|
@ -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<Type>,
|
||||||
|
Label = unknown
|
||||||
|
>(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
config:
|
||||||
|
| ChartConfiguration<Type, Data, Label>
|
||||||
|
| ChartConfigurationCustomTypesPerDataset<Type, Data, Label>
|
||||||
|
) {
|
||||||
|
const chart = new Chart<Type, Data, Label>(canvas, config);
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(
|
||||||
|
debounce(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
chart.resize();
|
||||||
|
});
|
||||||
|
}, 500)
|
||||||
|
);
|
||||||
|
|
||||||
|
resizeObserver.observe(canvas);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: (
|
||||||
|
config:
|
||||||
|
| ChartConfiguration<Type, Data, Label>
|
||||||
|
| ChartConfigurationCustomTypesPerDataset<Type, Data, Label>
|
||||||
|
) => {
|
||||||
|
chart.data = config.data;
|
||||||
|
chart.update();
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
chart.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
import FieldSuggestItem from "@ui/components/setting/FieldSuggestItem.svelte";
|
import FieldSuggestItem from "@ui/components/setting/FieldSuggestItem.svelte";
|
||||||
import ToggleItem from "@ui/components/setting/ToggleItem.svelte";
|
import ToggleItem from "@ui/components/setting/ToggleItem.svelte";
|
||||||
import type BookTrackerPlugin from "@src/main";
|
import type BookTrackerPlugin from "@src/main";
|
||||||
import { createSettingsStore } from "./store";
|
import { createSettings } from "@ui/stores/settings.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -16,92 +16,90 @@
|
||||||
const { plugin }: Props = $props();
|
const { plugin }: Props = $props();
|
||||||
const { app } = plugin;
|
const { app } = plugin;
|
||||||
|
|
||||||
const settings = createSettingsStore(plugin);
|
const settingsStore = createSettings(plugin);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => settingsStore.load());
|
||||||
await settings.load();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="obt-settings">
|
<div class="obt-settings">
|
||||||
<Header title="Folder Settings" />
|
<Header title="Folders" />
|
||||||
<FolderSuggestItem
|
<FolderSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="book-folder"
|
id="book-folder"
|
||||||
name="Book Folder"
|
name="Book Folder"
|
||||||
description="Select the folder where book entries are stored."
|
description="Select the folder where book entries are stored."
|
||||||
bind:value={$settings.bookFolder}
|
bind:value={settingsStore.settings.bookFolder}
|
||||||
/>
|
/>
|
||||||
<FolderSuggestItem
|
<FolderSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="tbr-folder"
|
id="tbr-folder"
|
||||||
name="To Be Read Folder"
|
name="To Be Read Folder"
|
||||||
description="Select the folder to use for To Be Read entries"
|
description="Select the folder to use for To Be Read entries"
|
||||||
bind:value={$settings.tbrFolder}
|
bind:value={settingsStore.settings.tbrFolder}
|
||||||
/>
|
/>
|
||||||
<FolderSuggestItem
|
<FolderSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="read-folder"
|
id="read-folder"
|
||||||
name="Read Books Folder"
|
name="Read Books Folder"
|
||||||
description="Select the folder to use for Read entries."
|
description="Select the folder to use for Read entries."
|
||||||
bind:value={$settings.readBooksFolder}
|
bind:value={settingsStore.settings.readBooksFolder}
|
||||||
/>
|
/>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
id="organize-read-books"
|
id="organize-read-books"
|
||||||
name="Organize Read Books"
|
name="Organize Read Books"
|
||||||
description="Organize read books into folders based on the date read."
|
description="Organize read books into folders based on the date read."
|
||||||
bind:checked={$settings.organizeReadBooks}
|
bind:checked={settingsStore.settings.organizeReadBooks}
|
||||||
/>
|
/>
|
||||||
<Header title="Book Creation Settings" />
|
<Header title="Book Creation" />
|
||||||
<FileSuggestItem
|
<FileSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="template-file"
|
id="template-file"
|
||||||
name="Template File"
|
name="Template File"
|
||||||
description="Select the template file to use for new book entries."
|
description="Select the template file to use for new book entries."
|
||||||
bind:value={$settings.templateFile}
|
bind:value={settingsStore.settings.templateFile}
|
||||||
/>
|
/>
|
||||||
<TextInputItem
|
<TextInputItem
|
||||||
id="file-name-format"
|
id="file-name-format"
|
||||||
name="File name Format"
|
name="File name Format"
|
||||||
description={`Format for the file name of new book entries.
|
description={`Format for the file name of new book entries.
|
||||||
Use {{title}} and {{authors}} as placeholders.`}
|
Use {{title}} and {{authors}} as placeholders.`}
|
||||||
bind:value={$settings.fileNameFormat}
|
bind:value={settingsStore.settings.fileNameFormat}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Header title="Cover Download Settings" />
|
<Header title="Cover Downloading" />
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
id="download-covers"
|
id="download-covers"
|
||||||
name="Download Covers"
|
name="Download Covers"
|
||||||
description="Automatically download book covers when creating new entries."
|
description="Automatically download book covers when creating new entries."
|
||||||
bind:checked={$settings.downloadCovers}
|
bind:checked={settingsStore.settings.downloadCovers}
|
||||||
/>
|
/>
|
||||||
<FolderSuggestItem
|
<FolderSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="cover-folder"
|
id="cover-folder"
|
||||||
name="Cover Folder"
|
name="Cover Folder"
|
||||||
description="Select the folder to download covers to."
|
description="Select the folder to download covers to."
|
||||||
bind:value={$settings.coverFolder}
|
bind:value={settingsStore.settings.coverFolder}
|
||||||
/>
|
/>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
id="group-covers"
|
id="group-covers"
|
||||||
name="Group Covers by First Letter"
|
name="Group Covers by First Letter"
|
||||||
description="Organize downloaded book covers into folders based on the first letter of the book title."
|
description="Organize downloaded book covers into folders based on the first letter of the book title."
|
||||||
bind:checked={$settings.groupCoversByFirstLetter}
|
bind:checked={settingsStore.settings.groupCoversByFirstLetter}
|
||||||
/>
|
/>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
id="overwrite-covers"
|
id="overwrite-covers"
|
||||||
name="Overwrite Existing Covers"
|
name="Overwrite Existing Covers"
|
||||||
description="Overwrite existing covers when downloading new ones."
|
description="Overwrite existing covers when downloading new ones."
|
||||||
bind:checked={$settings.overwriteExistingCovers}
|
bind:checked={settingsStore.settings.overwriteExistingCovers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Header title="Reading Progress Settings" />
|
<Header title="Reading Log" />
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
{app}
|
{app}
|
||||||
id="status-field"
|
id="status-field"
|
||||||
name="Status Field"
|
name="Status Field"
|
||||||
description="Select the field to use for reading status."
|
description="Select the field to use for reading status."
|
||||||
bind:value={$settings.statusProperty}
|
bind:value={settingsStore.settings.statusProperty}
|
||||||
accepts={["text"]}
|
accepts={["text"]}
|
||||||
/>
|
/>
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
|
@ -109,7 +107,7 @@
|
||||||
id="start-date-field"
|
id="start-date-field"
|
||||||
name="Start Date Field"
|
name="Start Date Field"
|
||||||
description="Select the field to use for start date."
|
description="Select the field to use for start date."
|
||||||
bind:value={$settings.startDateProperty}
|
bind:value={settingsStore.settings.startDateProperty}
|
||||||
accepts={["date"]}
|
accepts={["date"]}
|
||||||
/>
|
/>
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
|
@ -117,7 +115,7 @@
|
||||||
id="end-date-field"
|
id="end-date-field"
|
||||||
name="End Date Field"
|
name="End Date Field"
|
||||||
description="Select the field to use for end date."
|
description="Select the field to use for end date."
|
||||||
bind:value={$settings.endDateProperty}
|
bind:value={settingsStore.settings.endDateProperty}
|
||||||
accepts={["date"]}
|
accepts={["date"]}
|
||||||
/>
|
/>
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
|
@ -125,7 +123,7 @@
|
||||||
id="rating-field"
|
id="rating-field"
|
||||||
name="Rating Field"
|
name="Rating Field"
|
||||||
description="Select the field to use for rating."
|
description="Select the field to use for rating."
|
||||||
bind:value={$settings.ratingProperty}
|
bind:value={settingsStore.settings.ratingProperty}
|
||||||
accepts={["number"]}
|
accepts={["number"]}
|
||||||
/>
|
/>
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
|
@ -134,7 +132,7 @@
|
||||||
name="Spice Field"
|
name="Spice Field"
|
||||||
description={`Select the field to use for spice rating.
|
description={`Select the field to use for spice rating.
|
||||||
Set to empty to disable.`}
|
Set to empty to disable.`}
|
||||||
bind:value={$settings.spiceProperty}
|
bind:value={settingsStore.settings.spiceProperty}
|
||||||
accepts={["number"]}
|
accepts={["number"]}
|
||||||
/>
|
/>
|
||||||
<FieldSuggestItem
|
<FieldSuggestItem
|
||||||
|
@ -142,7 +140,7 @@
|
||||||
id="page-count-field"
|
id="page-count-field"
|
||||||
name="Page Count Field"
|
name="Page Count Field"
|
||||||
description="Select the field to use for page count."
|
description="Select the field to use for page count."
|
||||||
bind:value={$settings.pageCountProperty}
|
bind:value={settingsStore.settings.pageCountProperty}
|
||||||
accepts={["number"]}
|
accepts={["number"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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<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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
|
@ -1,19 +1,12 @@
|
||||||
import type { Storage } from "./Storage";
|
import type { Storage } from "./Storage";
|
||||||
|
import type { Moment } from "moment";
|
||||||
|
|
||||||
export interface ReadingLogEntry {
|
export interface ReadingLogEntry {
|
||||||
book: string;
|
book: string;
|
||||||
pagesRead: number;
|
pagesRead: number;
|
||||||
pagesReadTotal: number;
|
pagesReadTotal: number;
|
||||||
pagesRemaining: number;
|
pagesRemaining: number;
|
||||||
createdAt: Date;
|
createdAt: Moment;
|
||||||
}
|
|
||||||
|
|
||||||
function isSameDay(a: Date, b: Date): boolean {
|
|
||||||
return (
|
|
||||||
a.getFullYear() === b.getFullYear() &&
|
|
||||||
a.getMonth() === b.getMonth() &&
|
|
||||||
a.getDate() === b.getDate()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReadingLog {
|
export class ReadingLog {
|
||||||
|
@ -32,20 +25,28 @@ export class ReadingLog {
|
||||||
if (entries) {
|
if (entries) {
|
||||||
this.entries = entries.map((entry) => ({
|
this.entries = entries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
createdAt: new Date(entry.createdAt),
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
|
createdAt: moment(entry.createdAt),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortEntries() {
|
private sortEntries() {
|
||||||
this.entries = this.entries.sort(
|
this.entries = this.entries.sort((a, b) =>
|
||||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
a.createdAt.diff(b.createdAt)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(filename = "reading-log.json") {
|
async save(filename = "reading-log.json") {
|
||||||
this.sortEntries();
|
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[] {
|
public getEntries(): ReadingLogEntry[] {
|
||||||
|
@ -82,10 +83,14 @@ export class ReadingLog {
|
||||||
: pageEnded,
|
: pageEnded,
|
||||||
pagesReadTotal: pageEnded,
|
pagesReadTotal: pageEnded,
|
||||||
pagesRemaining: pageCount - 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;
|
newEntry.pagesRead += lastEntry.pagesRead;
|
||||||
await this.updateEntry(this.entries.indexOf(lastEntry), newEntry);
|
await this.updateEntry(this.entries.indexOf(lastEntry), newEntry);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export type FrequencyArray<T> = { value: T; count: number }[];
|
||||||
|
|
||||||
|
export function frequencyArray<T>(arr: T[]): FrequencyArray<T> {
|
||||||
|
const map = new Map<T, number>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
|
@ -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<void> {
|
||||||
|
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()!);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function groupBy<T>(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<string, T[]>);
|
||||||
|
}
|
Loading…
Reference in New Issue