Add reading calendar

This commit is contained in:
Evan Fiordeliso 2025-07-09 21:03:07 -04:00
parent ac10cf646f
commit ffb8cc8d9c
20 changed files with 494 additions and 97 deletions

View File

@ -1,9 +1,9 @@
{ {
"id": "obsidian-book-tracker", "id": "obsidian-book-tracker",
"name": "Book Tracker", "name": "Book Tracker",
"version": "1.0.0", "version": "1.1.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.", "description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.",
"author": "FiFiTiDo", "author": "FiFiTiDo",
"isDesktopOnly": false "isDesktopOnly": false
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "obsidian-book-tracker", "name": "obsidian-book-tracker",
"version": "1.0.0", "version": "1.1.0",
"description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.", "description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -24,6 +24,7 @@ import { Goodreads } from "@data-sources/Goodreads";
import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand"; import { CreateBookFromGoodreadsUrlCommand } from "@commands/CreateBookFromGoodreadsUrlCommand";
import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock"; import { registerShelfCodeBlockProcessor } from "@ui/code-blocks/ShelfCodeBlock";
import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand"; import { ReloadReadingLogCommand } from "@commands/ReloadReadingLogCommand";
import { registerReadingCalendarCodeBlockProcessor } from "@ui/code-blocks/ReadingCalendarCodeBlock";
export default class BookTrackerPlugin extends Plugin { export default class BookTrackerPlugin extends Plugin {
public settings: BookTrackerPluginSettings; public settings: BookTrackerPluginSettings;
@ -89,6 +90,7 @@ export default class BookTrackerPlugin extends Plugin {
registerReadingLogCodeBlockProcessor(this); registerReadingLogCodeBlockProcessor(this);
registerReadingStatsCodeBlockProcessor(this); registerReadingStatsCodeBlockProcessor(this);
registerShelfCodeBlockProcessor(this); registerShelfCodeBlockProcessor(this);
registerReadingCalendarCodeBlockProcessor(this);
} }
onunload() {} onunload() {}

View File

@ -0,0 +1,25 @@
import { registerCodeBlockRenderer } from ".";
import { SvelteCodeBlockRenderer } from "./SvelteCodeBlockRenderer";
import ReadingCalendarCodeBlockView from "./ReadingCalendarCodeBlockView.svelte";
import type BookTrackerPlugin from "@src/main";
import z from "zod/v4";
export function registerReadingCalendarCodeBlockProcessor(
plugin: BookTrackerPlugin
): void {
registerCodeBlockRenderer(
plugin,
"readingcalendar",
(source, el) =>
new SvelteCodeBlockRenderer(
ReadingCalendarCodeBlockView,
plugin,
source,
el
)
);
}
export const ReadingCalendarSettingsSchema = z.object({
coverProperty: z.string(),
});

View File

@ -0,0 +1,400 @@
<script lang="ts">
import { parseYaml } from "obsidian";
import { ReadingCalendarSettingsSchema } from "./ReadingCalendarCodeBlock";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
import {
createSettings,
setSettingsContext,
} from "@ui/stores/settings.svelte";
import {
createMetadata,
setMetadataContext,
} from "@ui/stores/metadata.svelte";
import {
createReadingLog,
setReadingLogContext,
} from "@ui/stores/reading-log.svelte";
import { ArrowLeft, ArrowRight } from "lucide-svelte";
import { onMount } from "svelte";
const { plugin, source }: SvelteCodeBlockProps = $props();
const settings = ReadingCalendarSettingsSchema.parse(parseYaml(source));
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
const metadataStore = createMetadata(plugin, null);
setMetadataContext(metadataStore);
const readingLog = createReadingLog(plugin.readingLog);
setReadingLogContext(readingLog);
let year = $state(new Date().getFullYear());
let month = $state(new Date().getMonth());
$effect(() => {
readingLog.filterYear = year;
readingLog.filterMonth = month + 1;
});
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const daysOfWeek = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
// @ts-expect-error Moment is provided by Obsidian
let today = $state(moment());
function msUntilMidnight() {
// @ts-expect-error Moment is provided by Obsidian
return moment().endOf("day").diff(today, "milliseconds");
}
function updateToday() {
setTimeout(() => {
// @ts-expect-error Moment is provided by Obsidian
today = moment();
updateToday();
}, msUntilMidnight() + 1000);
}
onMount(() => {
updateToday();
});
const weeks = $derived.by(() => {
// @ts-expect-error Moment is provided by Obsidian
const firstDay = moment()
.year(year)
.month(month)
.startOf("month")
.startOf("week");
// @ts-expect-error Moment is provided by Obsidian
const lastDay = moment()
.year(year)
.month(month)
.endOf("month")
.endOf("week");
const weeks = [];
let currentDay = firstDay.clone();
while (currentDay.isBefore(lastDay)) {
const week = [];
for (let i = 0; i < 7; i++) {
week.push(currentDay.clone());
currentDay.add(1, "day");
}
weeks.push(week);
}
return weeks;
});
interface CoverData {
src: string;
alt: string;
}
interface BookMapItem {
totalPagesRead: number;
covers: CoverData[];
}
const bookMap = $derived.by(() => {
const bookMap = new Map<number, BookMapItem>();
for (const item of readingLog.entries) {
const key = item.createdAt.date();
let coverPath = metadataStore.metadata.find(
(entry) => entry.file.basename === item.book,
)?.frontmatter?.[settings.coverProperty];
coverPath = plugin.app.vault.getFileByPath(coverPath);
if (!coverPath) {
continue;
}
coverPath = plugin.app.vault.getResourcePath(coverPath);
const value = bookMap.get(key) ?? { totalPagesRead: 0, covers: [] };
value.totalPagesRead += item.pagesRead;
value.covers.push({ src: coverPath, alt: item.book });
bookMap.set(key, value);
}
return bookMap;
});
</script>
<div class="reading-calendar">
<div class="controls">
<div class="left">
<button
class="prev-month"
aria-label="Go to previous month"
onclick={() => {
if (month === 0) {
month = 11;
year--;
} else {
month--;
}
}}
>
<ArrowLeft />
</button>
<button
class="today"
aria-label="Go to the current month"
onclick={() => {
year = today.year();
month = today.month();
}}
>
Today
</button>
</div>
<h2>{monthNames[month]} {year}</h2>
<button
class="next-month"
aria-label="Go to next month"
onclick={() => {
if (month === 11) {
month = 0;
year++;
} else {
month++;
}
}}
>
<ArrowRight />
</button>
</div>
<table>
<thead>
<tr>
{#each daysOfWeek as day}
<th>{day}</th>
{/each}
</tr>
</thead>
<tbody>
{#each weeks as week}
<tr>
{#each week as day}
{@const isThisMonth = day.month() === month}
<td
class:is-today={day.isSame(today, "day")}
class:is-weekend={day.day() === 0 ||
day.day() === 6}
class:is-prev-month={day.month() ===
(month === 0 ? 11 : month - 1)}
class:is-next-month={day.month() ===
(month + 1) % 12}
>
<div class="header">
<span>{day.date()}</span>
{#if isThisMonth && bookMap.has(day.date())}
{@const data = bookMap.get(day.date())!}
<span class="total-pages-read">
Pages: {data.totalPagesRead}
</span>
{/if}
</div>
<div class="covers">
{#if isThisMonth && bookMap.has(day.date())}
{@const data = bookMap.get(day.date())!}
{#each data.covers as cover}
{#if cover}
<img
src={cover.src}
alt={cover.alt}
/>
{/if}
{/each}
{/if}
</div>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<style lang="scss">
$cell-padding: var(--size-4-2);
.reading-calendar {
display: flex;
flex-direction: column;
overflow: auto;
.controls {
min-width: 800px;
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: var(--size-4-4);
align-items: center;
h2 {
text-align: center;
margin: 0;
}
.left {
display: flex;
gap: var(--size-2-2);
align-items: center;
}
}
}
table {
table-layout: fixed;
border-collapse: collapse;
min-width: 800px;
th {
padding: $cell-padding;
width: calc(100% / 7);
}
td {
padding: $cell-padding;
&.is-prev-month,
&.is-next-month {
color: var(--text-faint);
}
&.is-today {
color: var(--text-accent);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
.total-pages-read {
font-size: var(--font-smallest);
color: var(--text-muted);
}
}
.covers {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
img {
border-radius: var(--radius-l);
}
&:has(img:first-child:nth-last-child(1)) {
img {
position: absolute;
height: 100%;
width: 100%;
}
}
&:has(img:first-child:nth-last-child(2)) {
img:first-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
img:last-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(100% 100%, 100% 0, 0 100%);
}
}
&:has(img:first-child:nth-last-child(3)) {
img:first-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 0, 100% 0, 100% 20%, 0 50%);
}
img:nth-child(2) {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(100% 80%, 100% 20%, 0 50%);
}
img:last-child {
position: absolute;
height: 100%;
width: 100%;
clip-path: polygon(0 50%, 100% 80%, 100% 100%, 0% 100%);
}
}
&:has(img:first-child:nth-last-child(4)) {
img:first-child {
position: absolute;
height: 50%;
width: 50%;
top: 0;
left: 0;
}
img:nth-child(2) {
position: absolute;
height: 50%;
width: 50%;
top: 0;
right: 0;
}
img:nth-child(3) {
position: absolute;
height: 50%;
width: 50%;
bottom: 0;
left: 0;
}
img:last-child {
position: absolute;
height: 50%;
width: 50%;
bottom: 0;
right: 0;
}
}
}
}
}
</style>

View File

@ -9,16 +9,12 @@ export function registerReadingLogCodeBlockProcessor(
registerCodeBlockRenderer( registerCodeBlockRenderer(
plugin, plugin,
"readinglog", "readinglog",
(_source, el) => new ReadingLogCodeBlockRenderer(el, plugin) (source, el) =>
new SvelteCodeBlockRenderer(
ReadingLogCodeBlockView,
plugin,
source,
el
)
); );
} }
export class ReadingLogCodeBlockRenderer extends SvelteCodeBlockRenderer<
typeof ReadingLogCodeBlockView
> {
constructor(contentEl: HTMLElement, plugin: BookTrackerPlugin) {
super(contentEl, ReadingLogCodeBlockView, { props: { plugin } });
}
onunload() {}
}

View File

@ -2,18 +2,14 @@
import type { ReadingLogEntry } from "@utils/ReadingLog"; import type { ReadingLogEntry } from "@utils/ReadingLog";
import { Edit, Trash, Plus } from "lucide-svelte"; import { Edit, Trash, Plus } from "lucide-svelte";
import { ReadingLogEntryEditModal } from "@ui/modals"; import { ReadingLogEntryEditModal } from "@ui/modals";
import type BookTrackerPlugin from "@src/main";
import { createReadingLog } from "@ui/stores/reading-log.svelte"; import { createReadingLog } from "@ui/stores/reading-log.svelte";
import { ALL_TIME } from "@ui/stores/date-filter.svelte"; import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import OpenFileLink from "@ui/components/OpenFileLink.svelte"; import OpenFileLink from "@ui/components/OpenFileLink.svelte";
import { setAppContext } from "@ui/stores/app"; import { setAppContext } from "@ui/stores/app";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
interface Props { const { plugin }: SvelteCodeBlockProps = $props();
plugin: BookTrackerPlugin;
}
const { plugin }: Props = $props();
setAppContext(plugin.app); setAppContext(plugin.app);
const store = createReadingLog(plugin.readingLog); const store = createReadingLog(plugin.readingLog);
@ -32,7 +28,6 @@
const modal = new ReadingLogEntryEditModal( const modal = new ReadingLogEntryEditModal(
plugin, plugin,
async (entry) => { async (entry) => {
modal.close();
await store.updateEntry(entry); await store.updateEntry(entry);
}, },
entry, entry,

View File

@ -11,7 +11,13 @@ export function registerReadingStatsCodeBlockProcessor(
registerCodeBlockRenderer( registerCodeBlockRenderer(
plugin, plugin,
"readingstats", "readingstats",
(source, el) => new ReadingStatsCodeBlockRenderer(source, el, plugin) (source, el) =>
new SvelteCodeBlockRenderer(
ReadingStatsCodeBlockView,
plugin,
source,
el
)
); );
} }
@ -98,19 +104,3 @@ export const ReadingStatsSectionSchema = z.object({
}); });
export type ReadingStatsSection = z.infer<typeof ReadingStatsSectionSchema>; 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() {}
}

View File

@ -20,7 +20,6 @@
createSettings, createSettings,
setSettingsContext, setSettingsContext,
} from "@ui/stores/settings.svelte"; } from "@ui/stores/settings.svelte";
import type BookTrackerPlugin from "@src/main";
import BookCountStat from "@ui/components/stats/BookCountStat.svelte"; import BookCountStat from "@ui/components/stats/BookCountStat.svelte";
import { ALL_TIME } from "@ui/stores/date-filter.svelte"; import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { import {
@ -28,13 +27,9 @@
setReadingLogContext, setReadingLogContext,
} from "@ui/stores/reading-log.svelte"; } from "@ui/stores/reading-log.svelte";
import { setAppContext } from "@ui/stores/app"; import { setAppContext } from "@ui/stores/app";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
interface Props { const { plugin, source }: SvelteCodeBlockProps = $props();
plugin: BookTrackerPlugin;
source: string;
}
const { plugin, source }: Props = $props();
setAppContext(plugin.app); setAppContext(plugin.app);
const settingsStore = createSettings(plugin); const settingsStore = createSettings(plugin);

View File

@ -11,7 +11,8 @@ export function registerShelfCodeBlockProcessor(
registerCodeBlockRenderer( registerCodeBlockRenderer(
plugin, plugin,
"shelf", "shelf",
(source, el) => new ShelfCodeBlockRenderer(plugin, source, el) (source, el) =>
new SvelteCodeBlockRenderer(ShelfCodeBockView, plugin, source, el)
); );
} }
@ -33,17 +34,3 @@ export const ShelfSettingsSchema = z.object({
}); });
export type ShelfSettings = z.infer<typeof ShelfSettingsSchema>; export type ShelfSettings = z.infer<typeof ShelfSettingsSchema>;
export class ShelfCodeBlockRenderer extends SvelteCodeBlockRenderer<
typeof ShelfCodeBockView
> {
constructor(
plugin: BookTrackerPlugin,
source: string,
contentEl: HTMLElement
) {
super(contentEl, ShelfCodeBockView, { props: { plugin, source } });
}
onunload() {}
}

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { STATUS_READ } from "@src/const"; import { STATUS_READ } from "@src/const";
import type BookTrackerPlugin from "@src/main";
import { import {
createMetadata, createMetadata,
setMetadataContext, setMetadataContext,
@ -17,13 +16,9 @@
import TableView from "@ui/components/TableView.svelte"; import TableView from "@ui/components/TableView.svelte";
import DetailsView from "@ui/components/DetailsView.svelte"; import DetailsView from "@ui/components/DetailsView.svelte";
import { setAppContext } from "@ui/stores/app"; import { setAppContext } from "@ui/stores/app";
import type { SvelteCodeBlockProps } from "./SvelteCodeBlockRenderer";
interface Props { const { plugin, source }: SvelteCodeBlockProps = $props();
plugin: BookTrackerPlugin;
source: string;
}
const { plugin, source }: Props = $props();
setAppContext(plugin.app); setAppContext(plugin.app);
const settings = ShelfSettingsSchema.parse(parseYaml(source)); const settings = ShelfSettingsSchema.parse(parseYaml(source));

View File

@ -1,26 +1,34 @@
import { mount, unmount, type Component, type MountOptions } from "svelte"; import { mount, unmount, type Component } from "svelte";
import { MarkdownRenderChild } from "obsidian"; import { MarkdownRenderChild } from "obsidian";
import type BookTrackerPlugin from "@src/main";
export interface SvelteCodeBlockProps {
plugin: BookTrackerPlugin;
source: string;
}
export class SvelteCodeBlockRenderer< export class SvelteCodeBlockRenderer<
TComponent extends Component<TProps, TExports, TBindings>, TComponent extends Component<SvelteCodeBlockProps, TExports>,
TProps extends Record<string, any> = {}, TExports extends Record<string, any> = {}
TExports extends Record<string, any> = {},
TBindings extends keyof TProps | "" = string
> extends MarkdownRenderChild { > extends MarkdownRenderChild {
protected component: TExports | undefined; protected component: TExports | undefined;
constructor( constructor(
private readonly contentEl: HTMLElement,
private readonly componentCtor: TComponent, private readonly componentCtor: TComponent,
private readonly mountOpts: Omit<MountOptions<TProps>, "target"> private readonly plugin: BookTrackerPlugin,
private readonly source: string,
private readonly contentEl: HTMLElement
) { ) {
super(contentEl); super(contentEl);
} }
onload(): void { onload(): void {
this.component = mount(this.componentCtor, { this.component = mount(this.componentCtor, {
...this.mountOpts,
target: this.contentEl, target: this.contentEl,
props: {
plugin: this.plugin,
source: this.source,
},
}); });
} }

View File

@ -11,9 +11,7 @@ export class GoodreadsSearchModal extends SvelteModal<
goodreads: Goodreads, goodreads: Goodreads,
onSearch: (error: any, results: SearchResult[]) => void = () => {} onSearch: (error: any, results: SearchResult[]) => void = () => {}
) { ) {
super(app, GoodreadsSearchModalView, { super(app, GoodreadsSearchModalView, { goodreads, onSearch });
props: { goodreads, onSearch },
});
} }
static createAndOpen( static createAndOpen(

View File

@ -8,7 +8,7 @@ export class RatingModal extends SvelteModal<typeof RatingModalView> {
spiceConfigured: boolean, spiceConfigured: boolean,
onSubmit: (rating: number, spice: number) => void = () => {} onSubmit: (rating: number, spice: number) => void = () => {}
) { ) {
super(app, RatingModalView, { props: { spiceConfigured, onSubmit } }); super(app, RatingModalView, { spiceConfigured, onSubmit });
} }
static createAndOpen( static createAndOpen(

View File

@ -8,11 +8,16 @@ export class ReadingLogEntryEditModal extends SvelteModal<
> { > {
constructor( constructor(
plugin: BookTrackerPlugin, plugin: BookTrackerPlugin,
onSubmit?: (entry: ReadingLogEntry) => void, onSubmit: (entry: ReadingLogEntry) => void,
entry?: ReadingLogEntry entry?: ReadingLogEntry
) { ) {
super(plugin.app, ReadingLogEntryEditModalView, { super(plugin.app, ReadingLogEntryEditModalView, {
props: { plugin, entry, onSubmit }, plugin,
entry,
onSubmit: (entry: ReadingLogEntry) => {
onSubmit(entry);
this.close();
},
}); });
} }
} }

View File

@ -10,9 +10,7 @@ export class ReadingProgressModal extends SvelteModal<
pageCount: number, pageCount: number,
onSubmit: (pageNumber: number) => void = () => {} onSubmit: (pageNumber: number) => void = () => {}
) { ) {
super(app, ReadingProgressModalView, { super(app, ReadingProgressModalView, { pageCount, onSubmit });
props: { pageCount, onSubmit },
});
} }
static createAndOpen(app: App, pageCount: number): Promise<number> { static createAndOpen(app: App, pageCount: number): Promise<number> {

View File

@ -1,26 +1,23 @@
import { App, Modal } from "obsidian"; import { App, Modal } from "obsidian";
import { mount, unmount, type Component, type MountOptions } from "svelte"; import { mount, unmount, type Component, type ComponentProps } from "svelte";
export class SvelteModal< export class SvelteModal<
TComponent extends Component<TProps, TExports, TBindings>, TComponent extends Component<Record<string, any>, any, any>
TProps extends Record<string, any> = {},
TExports extends Record<string, any> = {},
TBindings extends keyof TProps | "" = string
> extends Modal { > extends Modal {
protected component: TExports | undefined; protected component: TComponent | undefined;
constructor( constructor(
app: App, app: App,
private readonly componentCtor: TComponent, private readonly componentCtor: TComponent,
private readonly mountOpts: Omit<MountOptions<TProps>, "target"> private readonly props: ComponentProps<TComponent>
) { ) {
super(app); super(app);
} }
onOpen(): void { onOpen(): void {
this.component = mount(this.componentCtor, { this.component = mount(this.componentCtor, {
...this.mountOpts,
target: this.contentEl, target: this.contentEl,
props: this.props,
}); });
} }

View File

@ -130,8 +130,13 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
} }
public async updateEntry(entry: ReadingLogEntry): Promise<void> { public async updateEntry(entry: ReadingLogEntry): Promise<void> {
this.entries[this.entries.findIndex((other) => other.id === entry.id)] = const index = this.entries.findIndex((other) => other.id === entry.id);
entry;
if (index === -1) {
throw new Error("Entry not found");
}
this.entries[index] = entry;
await this.save(); await this.save();
this.emit("updated", { entry }); this.emit("updated", { entry });
} }

View File

@ -23,7 +23,7 @@ export class EventEmitter<TEventMap> {
this.listeners[type].push(handler); this.listeners[type].push(handler);
return { return {
off() { off: () => {
this.off(type, handler); this.off(type, handler);
}, },
}; };

View File

@ -1,3 +1,4 @@
{ {
"1.0.0": "0.15.0" "1.0.0": "0.15.0",
} "1.1.0": "0.15.0"
}