generated from tpl/obsidian-sample-plugin
Add reading log store
This commit is contained in:
parent
eada608003
commit
d9cfb3df36
|
@ -45,6 +45,7 @@
|
|||
"dependencies": {
|
||||
"chart.js": "^4.5.0",
|
||||
"chroma-js": "^3.1.2",
|
||||
"uuid": "^11.1.0",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67"
|
||||
}
|
||||
|
|
|
@ -14,6 +14,9 @@ importers:
|
|||
chroma-js:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
yaml:
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0
|
||||
|
@ -1592,6 +1595,10 @@ packages:
|
|||
uri-js@4.4.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
|
@ -3189,6 +3196,8 @@ snapshots:
|
|||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
|
|
|
@ -58,7 +58,7 @@ export class LogReadingFinishedCommand extends EditorCheckCommand {
|
|||
);
|
||||
|
||||
try {
|
||||
await this.readingLog.addEntry(fileName, pageCount, pageCount);
|
||||
await this.readingLog.createEntry(fileName, pageCount, pageCount);
|
||||
} catch (error) {
|
||||
new Notice(
|
||||
`Failed to log reading progress for ${fileName}: ${error}`
|
||||
|
|
|
@ -59,7 +59,7 @@ export class LogReadingProgressCommand extends EditorCheckCommand {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.readingLog.addEntry(fileName, pageNumber, pageCount);
|
||||
await this.readingLog.createEntry(fileName, pageNumber, pageCount);
|
||||
} catch (error) {
|
||||
new Notice(
|
||||
`Failed to log reading progress for ${fileName}: ${error}`
|
||||
|
|
|
@ -43,7 +43,7 @@ export class ResetReadingStatusCommand extends EditorCheckCommand {
|
|||
frontMatter[this.settings.endDateProperty] = "";
|
||||
});
|
||||
|
||||
this.readingLog.removeEntries(file.basename);
|
||||
this.readingLog.removeEntriesForBook(file.basename);
|
||||
|
||||
new Notice("Reading status reset for " + file.basename);
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
import { Edit, Trash, Plus } from "lucide-svelte";
|
||||
import { ReadingLogEntryEditModal } from "@ui/modals";
|
||||
import type BookTrackerPlugin from "@src/main";
|
||||
|
||||
const ALL_TIME = "ALL_TIME";
|
||||
import { createReadingLog } from "@ui/stores/reading-log.svelte";
|
||||
import { ALL_TIME } from "@ui/stores/date-filter.svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
plugin: BookTrackerPlugin;
|
||||
|
@ -18,112 +19,60 @@
|
|||
return `obsidian://open?vault=${v}&file=${f}`;
|
||||
}
|
||||
|
||||
let entries = $state(
|
||||
plugin.readingLog.getEntries().map((entry, id) => ({
|
||||
...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)
|
||||
);
|
||||
});
|
||||
});
|
||||
const store = createReadingLog(plugin.readingLog);
|
||||
onDestroy(() => store.destroy());
|
||||
|
||||
function createEntry() {
|
||||
const modal = new ReadingLogEntryEditModal(plugin, async (entry) => {
|
||||
modal.close();
|
||||
await plugin.readingLog.addRawEntry(entry);
|
||||
reload();
|
||||
await store.addEntry(entry);
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
|
||||
function editEntry(i: number, entry: ReadingLogEntry) {
|
||||
function editEntry(entry: ReadingLogEntry) {
|
||||
const modal = new ReadingLogEntryEditModal(
|
||||
plugin,
|
||||
async (entry) => {
|
||||
modal.close();
|
||||
await plugin.readingLog.updateEntry(i, entry);
|
||||
reload();
|
||||
await store.updateEntry(entry);
|
||||
},
|
||||
entry,
|
||||
);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
async function deleteEntry(i: number) {
|
||||
await plugin.readingLog.spliceEntry(i);
|
||||
reload();
|
||||
async function removeEntry(entry: ReadingLogEntry) {
|
||||
await store.removeEntry(entry);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="obt-reading-log-viewer">
|
||||
<div class="controls">
|
||||
<div class="left">
|
||||
<select class="year-filter" bind:value={selectedYear}>
|
||||
{#each years as year}
|
||||
<option value={year.toString()}>{year}</option>
|
||||
<select class="year-filter" bind:value={store.filterYear}>
|
||||
{#each store.filterYears as year}
|
||||
<option value={year}>{year}</option>
|
||||
{/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>
|
||||
{#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 class="right">
|
||||
<button onclick={createEntry} class="create-entry" type="button">
|
||||
|
@ -144,7 +93,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredEntries as entry}
|
||||
{#each store.entries as entry}
|
||||
<tr>
|
||||
<td class="date">{entry.createdAt.format("YYYY-MM-DD")}</td>
|
||||
<td class="book"
|
||||
|
@ -159,11 +108,11 @@
|
|||
)}%
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button onclick={() => editEntry(entry.id, entry)}>
|
||||
<button onclick={() => editEntry(entry)}>
|
||||
<Edit />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button onclick={() => deleteEntry(entry.id)}>
|
||||
<button onclick={() => removeEntry(entry)}>
|
||||
<Trash />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
|
@ -171,7 +120,7 @@
|
|||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5">No entries found</td>
|
||||
<td colspan="5" class="no-entries">No entries found</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
@ -204,10 +153,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.year-filter:has(> option.all-time:checked) + .month-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
|
@ -228,6 +173,14 @@
|
|||
td.actions span {
|
||||
@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>
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
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";
|
||||
|
@ -23,6 +22,7 @@
|
|||
} from "@ui/stores/settings.svelte";
|
||||
import type BookTrackerPlugin from "@src/main";
|
||||
import BookCountStat from "@ui/components/stats/BookCountStat.svelte";
|
||||
import { ALL_TIME } from "@ui/stores/date-filter.svelte";
|
||||
|
||||
interface Props {
|
||||
plugin: BookTrackerPlugin;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
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 { Color } from "@utils/color";
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
|
@ -34,9 +35,11 @@
|
|||
|
||||
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"),
|
||||
.map((key) =>
|
||||
store.filterYear === ALL_TIME
|
||||
? key
|
||||
: // @ts-expect-error Moment is provided by Obsidian
|
||||
moment().month(key).format("MMM"),
|
||||
);
|
||||
const sortedBooks = Array.from(books.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
.label {
|
||||
text-transform: capitalize;
|
||||
font-weight: 600;
|
||||
font-weight: var(--bold-weight);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import type BookTrackerPlugin from "@src/main";
|
||||
import type { ReadingLogEntry } from "@utils/ReadingLog";
|
||||
import FileSuggest from "@ui/components/suggesters/FileSuggest.svelte";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface Props {
|
||||
plugin: BookTrackerPlugin;
|
||||
|
@ -16,7 +17,7 @@
|
|||
let pagesRead = $state(entry?.pagesRead ?? 0);
|
||||
let pagesReadTotal = $state(entry?.pagesReadTotal ?? 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
|
||||
function watch<T>(
|
||||
|
@ -48,11 +49,13 @@
|
|||
function onsubmit(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
onSubmit?.({
|
||||
id: entry?.id ?? uuidv4(),
|
||||
book,
|
||||
pagesRead,
|
||||
pagesReadTotal,
|
||||
pagesRemaining,
|
||||
createdAt: new Date(createdAt),
|
||||
// @ts-expect-error Moment is provided by Obsidian
|
||||
createdAt: moment(createdAt),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -3,6 +3,7 @@ import type { CachedMetadata, TFile } from "obsidian";
|
|||
import { getContext, setContext } from "svelte";
|
||||
import { getSettingsContext } from "./settings.svelte";
|
||||
import type BookTrackerPlugin from "@src/main";
|
||||
import { createDateFilter, type DateFilterStore } from "./date-filter.svelte";
|
||||
|
||||
export type FileMetadata = {
|
||||
file: TFile;
|
||||
|
@ -14,14 +15,8 @@ export type FileProperty = {
|
|||
value: any;
|
||||
};
|
||||
|
||||
export const ALL_TIME = "ALL_TIME";
|
||||
|
||||
interface MetadataStore {
|
||||
interface MetadataStore extends DateFilterStore {
|
||||
get metadata(): FileMetadata[];
|
||||
filterYear: number | typeof ALL_TIME;
|
||||
filterMonth: number | typeof ALL_TIME;
|
||||
get filterYears(): number[];
|
||||
get filterMonths(): { label: string; value: number }[];
|
||||
|
||||
destroy(): void;
|
||||
}
|
||||
|
@ -41,68 +36,7 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore {
|
|||
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) => {
|
||||
|
@ -123,27 +57,35 @@ export function createMetadata(plugin: BookTrackerPlugin): MetadataStore {
|
|||
}
|
||||
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 {
|
||||
get metadata() {
|
||||
return filteredMetadata;
|
||||
return dateFilter.filteredData;
|
||||
},
|
||||
get filterYear() {
|
||||
return filterYear;
|
||||
return dateFilter.filterYear;
|
||||
},
|
||||
set filterYear(value) {
|
||||
filterYear = value;
|
||||
dateFilter.filterYear = value;
|
||||
},
|
||||
get filterMonth() {
|
||||
return filterMonth;
|
||||
return dateFilter.filterMonth;
|
||||
},
|
||||
set filterMonth(value) {
|
||||
filterMonth = value;
|
||||
dateFilter.filterMonth = value;
|
||||
},
|
||||
get filterYears() {
|
||||
return filterYears;
|
||||
return dateFilter.filterYears;
|
||||
},
|
||||
get filterMonths() {
|
||||
return filterMonths;
|
||||
return dateFilter.filterMonths;
|
||||
},
|
||||
destroy() {
|
||||
plugin.app.metadataCache.off("changed", onChanged);
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
import type { Storage } from "./Storage";
|
||||
import type { Moment } from "moment";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EventEmitter } from "./event";
|
||||
|
||||
export interface ReadingLogEntry {
|
||||
id: string;
|
||||
book: string;
|
||||
pagesRead: number;
|
||||
pagesReadTotal: number;
|
||||
|
@ -9,10 +12,19 @@ export interface ReadingLogEntry {
|
|||
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[] = [];
|
||||
|
||||
public constructor(private readonly storage: Storage) {
|
||||
super();
|
||||
|
||||
this.load().catch((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
|
||||
createdAt: moment(entry.createdAt),
|
||||
}));
|
||||
this.emit("load", { entries: this.entries });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,7 +66,7 @@ export class ReadingLog {
|
|||
return this.entries;
|
||||
}
|
||||
|
||||
public getLastEntry(book: string): ReadingLogEntry | null {
|
||||
public getLastEntryForBook(book: string): ReadingLogEntry | null {
|
||||
const entriesForBook = this.entries.filter(
|
||||
(entry) => entry.book === book
|
||||
);
|
||||
|
@ -63,12 +76,12 @@ export class ReadingLog {
|
|||
: null;
|
||||
}
|
||||
|
||||
public async addEntry(
|
||||
public async createEntry(
|
||||
book: string,
|
||||
pageEnded: number,
|
||||
pageCount: number
|
||||
): Promise<void> {
|
||||
const lastEntry = this.getLastEntry(book);
|
||||
const lastEntry = this.getLastEntryForBook(book);
|
||||
|
||||
if (lastEntry && lastEntry.pagesReadTotal >= pageEnded) {
|
||||
throw new Error(
|
||||
|
@ -77,6 +90,7 @@ export class ReadingLog {
|
|||
}
|
||||
|
||||
const newEntry: ReadingLogEntry = {
|
||||
id: uuidv4(),
|
||||
book,
|
||||
pagesRead: lastEntry
|
||||
? pageEnded - lastEntry.pagesReadTotal
|
||||
|
@ -91,8 +105,9 @@ export class ReadingLog {
|
|||
lastEntry &&
|
||||
lastEntry.createdAt.isSame(newEntry.createdAt, "day")
|
||||
) {
|
||||
newEntry.id = lastEntry.id;
|
||||
newEntry.pagesRead += lastEntry.pagesRead;
|
||||
await this.updateEntry(this.entries.indexOf(lastEntry), newEntry);
|
||||
await this.updateEntry(newEntry);
|
||||
} else {
|
||||
await this.addRawEntry(newEntry);
|
||||
}
|
||||
|
@ -101,35 +116,31 @@ export class ReadingLog {
|
|||
public async addRawEntry(entry: ReadingLogEntry) {
|
||||
this.entries.push(entry);
|
||||
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.findIndex((other) => other.id === entry.id)] =
|
||||
entry;
|
||||
await this.save();
|
||||
this.emit("updated", { entry });
|
||||
}
|
||||
|
||||
public async removeEntry(entry: ReadingLogEntry): Promise<void> {
|
||||
const index = this.entries.findIndex((other) => other.id === entry.id);
|
||||
|
||||
if (index !== -1) {
|
||||
const removed = this.entries.splice(index, 1);
|
||||
await this.save();
|
||||
this.emit("removed", { entry: removed[0] });
|
||||
}
|
||||
}
|
||||
|
||||
public async removeEntriesForBook(book: string): Promise<void> {
|
||||
this.entries = this.entries.filter((entry) => entry.book !== book);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
public async removeLastEntry(book: string): Promise<void> {
|
||||
const latestEntryIndex = this.entries.findLastIndex(
|
||||
(entry) => entry.book === book
|
||||
);
|
||||
|
||||
if (latestEntryIndex !== -1) {
|
||||
this.entries.splice(latestEntryIndex, 1);
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> {
|
||||
this.entries[i] = entry;
|
||||
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> {
|
||||
this.entries = [];
|
||||
await this.save();
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue