generated from tpl/obsidian-sample-plugin
Add reading log store
This commit is contained in:
parent
eada608003
commit
d9cfb3df36
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}`
|
||||||
|
|
|
@ -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}`
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
font-weight: 600;
|
font-weight: var(--bold-weight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 { 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);
|
||||||
|
|
|
@ -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 { 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();
|
||||||
|
|
|
@ -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