Add reading log store

This commit is contained in:
Evan Fiordeliso 2025-07-03 20:29:55 -04:00
parent eada608003
commit d9cfb3df36
15 changed files with 386 additions and 197 deletions

View File

@ -45,6 +45,7 @@
"dependencies": { "dependencies": {
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"chroma-js": "^3.1.2", "chroma-js": "^3.1.2",
"uuid": "^11.1.0",
"yaml": "^2.8.0", "yaml": "^2.8.0",
"zod": "^3.25.67" "zod": "^3.25.67"
} }

View File

@ -14,6 +14,9 @@ importers:
chroma-js: chroma-js:
specifier: ^3.1.2 specifier: ^3.1.2
version: 3.1.2 version: 3.1.2
uuid:
specifier: ^11.1.0
version: 11.1.0
yaml: yaml:
specifier: ^2.8.0 specifier: ^2.8.0
version: 2.8.0 version: 2.8.0
@ -1592,6 +1595,10 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@ -3189,6 +3196,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
uuid@11.1.0: {}
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
dependencies: dependencies:
spdx-correct: 3.2.0 spdx-correct: 3.2.0

View File

@ -58,7 +58,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
); );
try { try {
await this.readingLog.addEntry(fileName, pageCount, pageCount); await this.readingLog.createEntry(fileName, pageCount, pageCount);
} catch (error) { } catch (error) {
new Notice( new Notice(
`Failed to log reading progress for ${fileName}: ${error}` `Failed to log reading progress for ${fileName}: ${error}`

View File

@ -59,7 +59,7 @@ export class LogReadingProgressCommand extends EditorCheckCommand {
} }
try { try {
await this.readingLog.addEntry(fileName, pageNumber, pageCount); await this.readingLog.createEntry(fileName, pageNumber, pageCount);
} catch (error) { } catch (error) {
new Notice( new Notice(
`Failed to log reading progress for ${fileName}: ${error}` `Failed to log reading progress for ${fileName}: ${error}`

View File

@ -43,7 +43,7 @@ export class ResetReadingStatusCommand extends EditorCheckCommand {
frontMatter[this.settings.endDateProperty] = ""; frontMatter[this.settings.endDateProperty] = "";
}); });
this.readingLog.removeEntries(file.basename); this.readingLog.removeEntriesForBook(file.basename);
new Notice("Reading status reset for " + file.basename); new Notice("Reading status reset for " + file.basename);
} }

View File

@ -3,8 +3,9 @@
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 type BookTrackerPlugin from "@src/main";
import { createReadingLog } from "@ui/stores/reading-log.svelte";
const ALL_TIME = "ALL_TIME"; import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { onDestroy } from "svelte";
interface Props { interface Props {
plugin: BookTrackerPlugin; plugin: BookTrackerPlugin;
@ -18,112 +19,60 @@
return `obsidian://open?vault=${v}&file=${f}`; return `obsidian://open?vault=${v}&file=${f}`;
} }
let entries = $state( const store = createReadingLog(plugin.readingLog);
plugin.readingLog.getEntries().map((entry, id) => ({ onDestroy(() => store.destroy());
...entry,
id,
})),
);
function reload() {
entries = plugin.readingLog.getEntries().map((entry, id) => ({
...entry,
id,
}));
}
const years = $derived([
...new Set(entries.map((entry) => entry.createdAt.year())),
]);
// @ts-expect-error Moment is provided by Obsidian
let selectedYear = $state(moment().year().toString());
const filterYear = $derived(
selectedYear === ALL_TIME ? ALL_TIME : parseInt(selectedYear, 10),
);
// @ts-expect-error Moment is provided by Obsidian
let selectedMonth = $state(moment().format("MM"));
const filterMonth = $derived(
selectedMonth === "" ? undefined : parseInt(selectedMonth, 10),
);
const filteredEntries = $derived.by(() => {
if (filterYear === ALL_TIME) {
return entries;
}
// @ts-expect-error Moment is provided by Obsidian
let startDate = moment().year(filterYear).startOf("year");
// @ts-expect-error Moment is provided by Obsidian
let endDate = moment().year(filterYear).endOf("year");
if (filterMonth !== undefined) {
startDate = startDate.month(filterMonth - 1).startOf("month");
endDate = endDate.month(filterMonth - 1).endOf("month");
}
return entries.filter((entry) => {
return (
entry.createdAt.isSameOrAfter(startDate) &&
entry.createdAt.isSameOrBefore(endDate)
);
});
});
function createEntry() { function createEntry() {
const modal = new ReadingLogEntryEditModal(plugin, async (entry) => { const modal = new ReadingLogEntryEditModal(plugin, async (entry) => {
modal.close(); modal.close();
await plugin.readingLog.addRawEntry(entry); await store.addEntry(entry);
reload();
}); });
modal.open(); modal.open();
} }
function editEntry(i: number, entry: ReadingLogEntry) { function editEntry(entry: ReadingLogEntry) {
const modal = new ReadingLogEntryEditModal( const modal = new ReadingLogEntryEditModal(
plugin, plugin,
async (entry) => { async (entry) => {
modal.close(); modal.close();
await plugin.readingLog.updateEntry(i, entry); await store.updateEntry(entry);
reload();
}, },
entry, entry,
); );
modal.open(); modal.open();
} }
async function deleteEntry(i: number) { async function removeEntry(entry: ReadingLogEntry) {
await plugin.readingLog.spliceEntry(i); await store.removeEntry(entry);
reload();
} }
</script> </script>
<div class="obt-reading-log-viewer"> <div class="obt-reading-log-viewer">
<div class="controls"> <div class="controls">
<div class="left"> <div class="left">
<select class="year-filter" bind:value={selectedYear}> <select class="year-filter" bind:value={store.filterYear}>
{#each years as year} {#each store.filterYears as year}
<option value={year.toString()}>{year}</option> <option value={year}>{year}</option>
{/each} {/each}
<option class="all-time" value={ALL_TIME}>All Time</option> <option value={ALL_TIME}>All Time</option>
</select>
<select class="month-filter" bind:value={selectedMonth}>
<option value="">Select Month</option>
<option value="01">January</option>
<option value="02">February</option>
<option value="03">March</option>
<option value="04">April</option>
<option value="05">May</option>
<option value="06">June</option>
<option value="07">July</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select> </select>
{#if store.filterYear !== ALL_TIME}
<select class="month-filter" bind:value={store.filterMonth}>
<option value={ALL_TIME}>Select Month</option>
<option value={1}>January</option>
<option value={2}>February</option>
<option value={3}>March</option>
<option value={4}>April</option>
<option value={5}>May</option>
<option value={6}>June</option>
<option value={7}>July</option>
<option value={8}>August</option>
<option value={9}>September</option>
<option value={10}>October</option>
<option value={11}>November</option>
<option value={12}>December</option>
</select>
{/if}
</div> </div>
<div class="right"> <div class="right">
<button onclick={createEntry} class="create-entry" type="button"> <button onclick={createEntry} class="create-entry" type="button">
@ -144,7 +93,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each filteredEntries as entry} {#each store.entries as entry}
<tr> <tr>
<td class="date">{entry.createdAt.format("YYYY-MM-DD")}</td> <td class="date">{entry.createdAt.format("YYYY-MM-DD")}</td>
<td class="book" <td class="book"
@ -159,11 +108,11 @@
)}% )}%
</td> </td>
<td class="actions"> <td class="actions">
<button onclick={() => editEntry(entry.id, entry)}> <button onclick={() => editEntry(entry)}>
<Edit /> <Edit />
<span>Edit</span> <span>Edit</span>
</button> </button>
<button onclick={() => deleteEntry(entry.id)}> <button onclick={() => removeEntry(entry)}>
<Trash /> <Trash />
<span>Delete</span> <span>Delete</span>
</button> </button>
@ -171,7 +120,7 @@
</tr> </tr>
{:else} {:else}
<tr> <tr>
<td colspan="5">No entries found</td> <td colspan="5" class="no-entries">No entries found</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@ -204,10 +153,6 @@
} }
} }
.year-filter:has(> option.all-time:checked) + .month-filter {
display: none;
}
table { table {
width: 100%; width: 100%;
@ -228,6 +173,14 @@
td.actions span { td.actions span {
@include utils.visually-hidden; @include utils.visually-hidden;
} }
td.no-entries {
text-align: center;
font-size: 1.5rem;
font-weight: var(--bold-weight);
font-style: italic;
padding: var(--size-4-6);
}
} }
} }
</style> </style>

View File

@ -7,7 +7,6 @@
import CountStat from "@ui/components/stats/CountStat.svelte"; import CountStat from "@ui/components/stats/CountStat.svelte";
import TotalStat from "@ui/components/stats/TotalStat.svelte"; import TotalStat from "@ui/components/stats/TotalStat.svelte";
import { import {
ALL_TIME,
createMetadata, createMetadata,
setMetadataContext, setMetadataContext,
} from "@ui/stores/metadata.svelte"; } from "@ui/stores/metadata.svelte";
@ -23,6 +22,7 @@
} from "@ui/stores/settings.svelte"; } from "@ui/stores/settings.svelte";
import type BookTrackerPlugin from "@src/main"; 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";
interface Props { interface Props {
plugin: BookTrackerPlugin; plugin: BookTrackerPlugin;

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { chart } from "@ui/directives/chart"; import { chart } from "@ui/directives/chart";
import { ALL_TIME, getMetadataContext } from "@ui/stores/metadata.svelte"; import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { getMetadataContext } from "@ui/stores/metadata.svelte";
import { getSettingsContext } from "@ui/stores/settings.svelte"; import { getSettingsContext } from "@ui/stores/settings.svelte";
import { Color } from "@utils/color"; import { Color } from "@utils/color";
import type { ChartConfiguration } from "chart.js"; import type { ChartConfiguration } from "chart.js";
@ -34,9 +35,11 @@
const labels = Array.from(books.keys()) const labels = Array.from(books.keys())
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map((m) => .map((key) =>
// @ts-expect-error Moment is provided by Obsidian store.filterYear === ALL_TIME
moment().month(m).format("MMM"), ? key
: // @ts-expect-error Moment is provided by Obsidian
moment().month(key).format("MMM"),
); );
const sortedBooks = Array.from(books.entries()) const sortedBooks = Array.from(books.entries())
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])

View File

@ -26,7 +26,7 @@
.label { .label {
text-transform: capitalize; text-transform: capitalize;
font-weight: 600; font-weight: var(--bold-weight);
} }
} }
</style> </style>

View File

@ -2,6 +2,7 @@
import type BookTrackerPlugin from "@src/main"; import type BookTrackerPlugin from "@src/main";
import type { ReadingLogEntry } from "@utils/ReadingLog"; import type { ReadingLogEntry } from "@utils/ReadingLog";
import FileSuggest from "@ui/components/suggesters/FileSuggest.svelte"; import FileSuggest from "@ui/components/suggesters/FileSuggest.svelte";
import { v4 as uuidv4 } from "uuid";
interface Props { interface Props {
plugin: BookTrackerPlugin; plugin: BookTrackerPlugin;
@ -16,7 +17,7 @@
let pagesRead = $state(entry?.pagesRead ?? 0); let pagesRead = $state(entry?.pagesRead ?? 0);
let pagesReadTotal = $state(entry?.pagesReadTotal ?? 0); let pagesReadTotal = $state(entry?.pagesReadTotal ?? 0);
let pagesRemaining = $state(entry?.pagesRemaining ?? 0); let pagesRemaining = $state(entry?.pagesRemaining ?? 0);
let createdAt = $state(entry?.createdAt ?? new Date()); let createdAt = $state(entry?.createdAt?.toDate() ?? new Date());
// Source: https://github.com/sveltejs/svelte/discussions/14220#discussioncomment-11188219 // Source: https://github.com/sveltejs/svelte/discussions/14220#discussioncomment-11188219
function watch<T>( function watch<T>(
@ -48,11 +49,13 @@
function onsubmit(ev: SubmitEvent) { function onsubmit(ev: SubmitEvent) {
ev.preventDefault(); ev.preventDefault();
onSubmit?.({ onSubmit?.({
id: entry?.id ?? uuidv4(),
book, book,
pagesRead, pagesRead,
pagesReadTotal, pagesReadTotal,
pagesRemaining, pagesRemaining,
createdAt: new Date(createdAt), // @ts-expect-error Moment is provided by Obsidian
createdAt: moment(createdAt),
}); });
} }
</script> </script>

View File

@ -0,0 +1,97 @@
import type { Moment } from "moment";
export const ALL_TIME = "ALL_TIME";
export interface DateFilterStore {
filterYear: number | typeof ALL_TIME;
get filterYears(): number[];
filterMonth: number | typeof ALL_TIME;
get filterMonths(): { label: string; value: number }[];
}
export function createDateFilter<T>(
data: () => T[],
selector: (item: T) => Moment,
initialMonth?: boolean
): DateFilterStore & {
filteredData: T[];
} {
const today = new Date();
let filterYear: number | typeof ALL_TIME = $state(today.getFullYear());
let filterMonth: number | typeof ALL_TIME = $state(
initialMonth ? today.getMonth() + 1 : ALL_TIME
);
const filteredData = $derived.by(() => {
return data().filter((item) => {
const date = selector(item);
if (filterYear !== ALL_TIME) {
if (date.year() !== filterYear) {
return false;
}
}
if (filterMonth !== ALL_TIME) {
if (date.month() !== filterMonth - 1) {
return false;
}
}
return true;
});
});
const filterYears = $derived.by(() => {
const years = new Set<number>();
data().forEach((item) => {
years.add(selector(item).year());
});
return Array.from(years).sort((a, b) => a - b);
});
const filterMonths = $derived.by(() => {
if (filterYear === ALL_TIME) {
return [];
}
const months: { label: string; value: number }[] = [];
data().forEach((item) => {
const date = selector(item);
if (date.year() === filterYear) {
months.push({
label: date.format("MMMM"),
value: date.month() + 1,
});
}
});
return months
.filter(
(month, idx, self) =>
idx === self.findIndex((m) => m.value === month.value)
)
.sort((a, b) => a.value - b.value);
});
return {
get filterYear() {
return filterYear;
},
set filterYear(value) {
filterYear = value;
},
get filterYears() {
return filterYears;
},
get filterMonth() {
return filterMonth;
},
set filterMonth(value) {
filterMonth = value;
},
get filterMonths() {
return filterMonths;
},
get filteredData() {
return filteredData;
},
};
}

View File

@ -3,6 +3,7 @@ import type { CachedMetadata, TFile } from "obsidian";
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { getSettingsContext } from "./settings.svelte"; import { getSettingsContext } from "./settings.svelte";
import type BookTrackerPlugin from "@src/main"; import type BookTrackerPlugin from "@src/main";
import { createDateFilter, type DateFilterStore } from "./date-filter.svelte";
export type FileMetadata = { export type FileMetadata = {
file: TFile; file: TFile;
@ -14,14 +15,8 @@ export type FileProperty = {
value: any; value: any;
}; };
export const ALL_TIME = "ALL_TIME"; interface MetadataStore extends DateFilterStore {
interface MetadataStore {
get metadata(): FileMetadata[]; get metadata(): FileMetadata[];
filterYear: number | typeof ALL_TIME;
filterMonth: number | typeof ALL_TIME;
get filterYears(): number[];
get filterMonths(): { label: string; value: number }[];
destroy(): void; destroy(): void;
} }
@ -41,68 +36,7 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore {
initialMetadata.push({ file, frontmatter }); initialMetadata.push({ file, frontmatter });
} }
const thisYear = new Date().getFullYear();
let metadata = $state(initialMetadata); 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) { function onChanged(file: TFile, _data: string, cache: CachedMetadata) {
metadata = metadata.map((f) => { metadata = metadata.map((f) => {
@ -123,27 +57,35 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore {
} }
plugin.registerEvent(plugin.app.metadataCache.on("deleted", onDeleted)); plugin.registerEvent(plugin.app.metadataCache.on("deleted", onDeleted));
const dateFilter = createDateFilter(
() => metadata,
(f) => {
// @ts-expect-error Moment is provided by Obsidian
return moment(f.frontmatter[settings.endDateProperty]);
}
);
return { return {
get metadata() { get metadata() {
return filteredMetadata; return dateFilter.filteredData;
}, },
get filterYear() { get filterYear() {
return filterYear; return dateFilter.filterYear;
}, },
set filterYear(value) { set filterYear(value) {
filterYear = value; dateFilter.filterYear = value;
}, },
get filterMonth() { get filterMonth() {
return filterMonth; return dateFilter.filterMonth;
}, },
set filterMonth(value) { set filterMonth(value) {
filterMonth = value; dateFilter.filterMonth = value;
}, },
get filterYears() { get filterYears() {
return filterYears; return dateFilter.filterYears;
}, },
get filterMonths() { get filterMonths() {
return filterMonths; return dateFilter.filterMonths;
}, },
destroy() { destroy() {
plugin.app.metadataCache.off("changed", onChanged); plugin.app.metadataCache.off("changed", onChanged);

View File

@ -0,0 +1,111 @@
import type { ReadingLog, ReadingLogEntry } from "@utils/ReadingLog";
import { getContext, setContext } from "svelte";
import { createDateFilter, type DateFilterStore } from "./date-filter.svelte";
export interface ReadingLogStore extends DateFilterStore {
get entries(): ReadingLogEntry[];
addEntry(entry: ReadingLogEntry): Promise<void>;
updateEntry(entry: ReadingLogEntry): Promise<void>;
removeEntry(entry: ReadingLogEntry): Promise<void>;
destroy(): void;
}
export function createReadingLog(readingLog: ReadingLog): ReadingLogStore {
let entries: ReadingLogEntry[] = $state(readingLog.getEntries());
const dateFilter = createDateFilter(
() => entries,
(entry) => entry.createdAt,
true
);
async function addEntry(entry: ReadingLogEntry): Promise<void> {
await readingLog.createEntry(
entry.book,
entry.pagesRead,
entry.pagesReadTotal
);
}
async function updateEntry(entry: ReadingLogEntry): Promise<void> {
await readingLog.updateEntry(entry);
}
async function removeEntry(entry: ReadingLogEntry): Promise<void> {
await readingLog.removeEntry(entry);
}
const loadHandler = readingLog.on("load", (payload) => {
entries = payload.entries;
});
const createdHandler = readingLog.on("created", (payload) => {
entries.push(payload.entry);
});
const updatedHandler = readingLog.on("updated", (payload) => {
const index = entries.findIndex(
(entry) => entry.id === payload.entry.id
);
if (index !== -1) {
entries[index] = payload.entry;
}
});
const removedHandler = readingLog.on("removed", (payload) => {
const index = entries.findIndex(
(entry) => entry.id === payload.entry.id
);
if (index !== -1) {
entries.splice(index, 1);
}
});
const sortedData = $derived(
dateFilter.filteredData.sort((a, b) => a.createdAt.diff(b.createdAt))
);
return {
get entries() {
return sortedData;
},
get filterYear() {
return dateFilter.filterYear;
},
set filterYear(value) {
dateFilter.filterYear = value;
},
get filterMonth() {
return dateFilter.filterMonth;
},
set filterMonth(value) {
dateFilter.filterMonth = value;
},
get filterYears() {
return dateFilter.filterYears;
},
get filterMonths() {
return dateFilter.filterMonths;
},
addEntry,
updateEntry,
removeEntry,
destroy() {
loadHandler.off();
createdHandler.off();
updatedHandler.off();
removedHandler.off();
},
};
}
const READING_LOG_KEY = Symbol("readingLog");
export function setReadingLogContext(state: ReadingLogStore) {
setContext(READING_LOG_KEY, state);
}
export function getReadingLogContext(): ReadingLogStore {
return getContext(READING_LOG_KEY);
}

View File

@ -1,7 +1,10 @@
import type { Storage } from "./Storage"; import type { Storage } from "./Storage";
import type { Moment } from "moment"; import type { Moment } from "moment";
import { v4 as uuidv4 } from "uuid";
import { EventEmitter } from "./event";
export interface ReadingLogEntry { export interface ReadingLogEntry {
id: string;
book: string; book: string;
pagesRead: number; pagesRead: number;
pagesReadTotal: number; pagesReadTotal: number;
@ -9,10 +12,19 @@ export interface ReadingLogEntry {
createdAt: Moment; createdAt: Moment;
} }
export class ReadingLog { interface ReadingLogEventMap {
load: { entries: ReadingLogEntry[] };
created: { entry: ReadingLogEntry };
updated: { entry: ReadingLogEntry };
removed: { entry: ReadingLogEntry };
}
export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
private entries: ReadingLogEntry[] = []; private entries: ReadingLogEntry[] = [];
public constructor(private readonly storage: Storage) { public constructor(private readonly storage: Storage) {
super();
this.load().catch((error) => { this.load().catch((error) => {
console.error("Failed to load reading log entries:", error); console.error("Failed to load reading log entries:", error);
}); });
@ -28,6 +40,7 @@ export class ReadingLog {
// @ts-expect-error Moment is provided by Obsidian // @ts-expect-error Moment is provided by Obsidian
createdAt: moment(entry.createdAt), createdAt: moment(entry.createdAt),
})); }));
this.emit("load", { entries: this.entries });
} }
} }
@ -53,7 +66,7 @@ export class ReadingLog {
return this.entries; return this.entries;
} }
public getLastEntry(book: string): ReadingLogEntry | null { public getLastEntryForBook(book: string): ReadingLogEntry | null {
const entriesForBook = this.entries.filter( const entriesForBook = this.entries.filter(
(entry) => entry.book === book (entry) => entry.book === book
); );
@ -63,12 +76,12 @@ export class ReadingLog {
: null; : null;
} }
public async addEntry( public async createEntry(
book: string, book: string,
pageEnded: number, pageEnded: number,
pageCount: number pageCount: number
): Promise<void> { ): Promise<void> {
const lastEntry = this.getLastEntry(book); const lastEntry = this.getLastEntryForBook(book);
if (lastEntry && lastEntry.pagesReadTotal >= pageEnded) { if (lastEntry && lastEntry.pagesReadTotal >= pageEnded) {
throw new Error( throw new Error(
@ -77,6 +90,7 @@ export class ReadingLog {
} }
const newEntry: ReadingLogEntry = { const newEntry: ReadingLogEntry = {
id: uuidv4(),
book, book,
pagesRead: lastEntry pagesRead: lastEntry
? pageEnded - lastEntry.pagesReadTotal ? pageEnded - lastEntry.pagesReadTotal
@ -91,8 +105,9 @@ export class ReadingLog {
lastEntry && lastEntry &&
lastEntry.createdAt.isSame(newEntry.createdAt, "day") lastEntry.createdAt.isSame(newEntry.createdAt, "day")
) { ) {
newEntry.id = lastEntry.id;
newEntry.pagesRead += lastEntry.pagesRead; newEntry.pagesRead += lastEntry.pagesRead;
await this.updateEntry(this.entries.indexOf(lastEntry), newEntry); await this.updateEntry(newEntry);
} else { } else {
await this.addRawEntry(newEntry); await this.addRawEntry(newEntry);
} }
@ -101,35 +116,31 @@ export class ReadingLog {
public async addRawEntry(entry: ReadingLogEntry) { public async addRawEntry(entry: ReadingLogEntry) {
this.entries.push(entry); this.entries.push(entry);
await this.save(); await this.save();
this.emit("created", { entry });
} }
public async removeEntries(book: string): Promise<void> { public async updateEntry(entry: ReadingLogEntry): Promise<void> {
this.entries = this.entries.filter((entry) => entry.book !== book); this.entries[this.entries.findIndex((other) => other.id === entry.id)] =
entry;
await this.save(); await this.save();
this.emit("updated", { entry });
} }
public async removeLastEntry(book: string): Promise<void> { public async removeEntry(entry: ReadingLogEntry): Promise<void> {
const latestEntryIndex = this.entries.findLastIndex( const index = this.entries.findIndex((other) => other.id === entry.id);
(entry) => entry.book === book
);
if (latestEntryIndex !== -1) { if (index !== -1) {
this.entries.splice(latestEntryIndex, 1); const removed = this.entries.splice(index, 1);
await this.save(); await this.save();
this.emit("removed", { entry: removed[0] });
} }
} }
public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> { public async removeEntriesForBook(book: string): Promise<void> {
this.entries[i] = entry; this.entries = this.entries.filter((entry) => entry.book !== book);
await this.save(); await this.save();
} }
public async spliceEntry(i: number): Promise<ReadingLogEntry> {
const entry = this.entries.splice(i, 1);
await this.save();
return entry[0];
}
public async clearEntries(): Promise<void> { public async clearEntries(): Promise<void> {
this.entries = []; this.entries = [];
await this.save(); await this.save();

59
src/utils/event.ts Normal file
View File

@ -0,0 +1,59 @@
export type EventHandler<T> = (payload: T) => void;
export type ListenerRef = {
off(): void;
};
export type EventHandlerMap<TEventMap, T extends keyof TEventMap> = Record<
T,
EventHandler<TEventMap[T]>[]
>;
export class EventEmitter<TEventMap> {
private readonly listeners: EventHandlerMap<TEventMap, keyof TEventMap> =
{} as EventHandlerMap<TEventMap, keyof TEventMap>;
public on<T extends keyof TEventMap>(
type: T,
handler: EventHandler<TEventMap[T]>
): ListenerRef {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(handler);
return {
off() {
this.off(type, handler);
},
};
}
public off<T extends keyof TEventMap>(
type: T,
handler: EventHandler<TEventMap[T]>
) {
if (this.listeners[type]) {
this.listeners[type] = this.listeners[type].filter(
(h) => h !== handler
);
}
}
public once<T extends keyof TEventMap>(
type: T,
handler: EventHandler<TEventMap[T]>
) {
const wrappedHandler = (payload: TEventMap[T]) => {
handler(payload);
this.off(type, wrappedHandler);
};
this.on(type, wrappedHandler);
}
public emit<T extends keyof TEventMap>(type: T, payload: TEventMap[T]) {
if (this.listeners[type]) {
this.listeners[type].forEach((handler) => handler(payload));
}
}
}