Improve reading log entry modal by grabbing metadata of selected book and updating pages remaining

This commit is contained in:
Evan Fiordeliso 2025-07-09 17:43:41 -04:00
parent c603475f69
commit ac10cf646f
8 changed files with 114 additions and 38 deletions

View File

@ -36,7 +36,7 @@ export default class BookTrackerPlugin extends Plugin {
await this.loadSettings(); await this.loadSettings();
this.templater = new Templater(this.app); this.templater = new Templater(this.app);
this.storage = new Storage(this.app, this); this.storage = new Storage(this);
this.readingLog = new ReadingLog(this.storage); this.readingLog = new ReadingLog(this.storage);
this.addCommand( this.addCommand(

View File

@ -5,7 +5,7 @@
import type BookTrackerPlugin from "@src/main"; import type BookTrackerPlugin from "@src/main";
import { createReadingLog } from "@ui/stores/reading-log.svelte"; import { createReadingLog } from "@ui/stores/reading-log.svelte";
import { ALL_TIME } from "@ui/stores/date-filter.svelte"; import { ALL_TIME } from "@ui/stores/date-filter.svelte";
import { onDestroy } from "svelte"; import { onDestroy, onMount } from "svelte";
import OpenFileLink from "@ui/components/OpenFileLink.svelte"; import OpenFileLink from "@ui/components/OpenFileLink.svelte";
import { setAppContext } from "@ui/stores/app"; import { setAppContext } from "@ui/stores/app";
@ -17,6 +17,7 @@
setAppContext(plugin.app); setAppContext(plugin.app);
const store = createReadingLog(plugin.readingLog); const store = createReadingLog(plugin.readingLog);
onMount(() => store.load());
onDestroy(() => store.destroy()); onDestroy(() => store.destroy());
function createEntry() { function createEntry() {

View File

@ -3,6 +3,12 @@
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"; import { v4 as uuidv4 } from "uuid";
import { createPrevious } from "@ui/stores/previous.svelte";
import { createMetadata } from "@ui/stores/metadata.svelte";
import {
createSettings,
setSettingsContext,
} from "@ui/stores/settings.svelte";
const INPUT_DATETIME_FORMAT = "YYYY-MM-DDTHH:mm"; const INPUT_DATETIME_FORMAT = "YYYY-MM-DDTHH:mm";
@ -14,9 +20,18 @@
let { plugin, entry, onSubmit }: Props = $props(); let { plugin, entry, onSubmit }: Props = $props();
const settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
const metadataStore = createMetadata(plugin, null);
let editMode = $derived(entry !== undefined); let editMode = $derived(entry !== undefined);
let book = $state(entry?.book ?? ""); let book = $state(entry?.book ?? "");
let bookMetadata = $derived(
metadataStore.metadata.find((m) => m.file.basename === book),
);
let pagesRead = $state(entry?.pagesRead ?? 0); let pagesRead = $state(entry?.pagesRead ?? 0);
let pagesReadPrev = createPrevious(() => pagesRead);
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( let createdAt = $state(
@ -25,32 +40,26 @@
moment().format(INPUT_DATETIME_FORMAT), moment().format(INPUT_DATETIME_FORMAT),
); );
// Source: https://github.com/sveltejs/svelte/discussions/14220#discussioncomment-11188219 $effect(() => {
function watch<T>( const diff = pagesRead - (pagesReadPrev.value ?? 0);
getter: () => T, pagesRead = pagesRead;
effectCallback: (t: T | undefined) => void, pagesReadTotal = pagesReadTotal + diff;
) { });
let previous: T | undefined = undefined;
$effect(() => { $effect(() => {
const current = getter(); // add $state.snapshot for deep reactivity pagesRemaining =
const cleanup = effectCallback(previous); (bookMetadata?.frontmatter?.[
previous = current; settingsStore.settings.pageCountProperty
] ?? 0) - pagesReadTotal;
});
return cleanup; $effect(() => {
}); pagesReadTotal = Math.max(pagesReadTotal, pagesRead);
} });
watch( $effect(() => {
() => pagesRead, pagesRemaining = Math.max(pagesRemaining, 0);
(prev) => { });
if (prev !== pagesRead && prev !== undefined) {
const diff = pagesRead - prev;
pagesReadTotal = pagesReadTotal + diff;
pagesRemaining = pagesRemaining - diff;
}
},
);
function onsubmit(ev: SubmitEvent) { function onsubmit(ev: SubmitEvent) {
ev.preventDefault(); ev.preventDefault();
@ -95,7 +104,9 @@
/> />
<label for="pagesRemaining">Pages Remaining</label> <label for="pagesRemaining">Pages Remaining</label>
<input <input
type="number" type="text"
inputmode="numeric"
pattern="[0-9]*"
name="pagesRemaining" name="pagesRemaining"
id="pagesRemaining" id="pagesRemaining"
bind:value={pagesRemaining} bind:value={pagesRemaining}

View File

@ -1,7 +1,11 @@
import { STATUS_READ } from "@src/const"; import { STATUS_READ } from "@src/const";
import type { CachedMetadata, TFile } from "obsidian"; import type { CachedMetadata, TFile } from "obsidian";
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { getSettingsContext } from "./settings.svelte"; import {
createSettings,
getSettingsContext,
setSettingsContext,
} from "./settings.svelte";
import type BookTrackerPlugin from "@src/main"; import type BookTrackerPlugin from "@src/main";
import { createDateFilter, type DateFilterStore } from "./date-filter.svelte"; import { createDateFilter, type DateFilterStore } from "./date-filter.svelte";
import type { ReadingState } from "@src/types"; import type { ReadingState } from "@src/types";
@ -26,14 +30,17 @@ export interface MetadataStore extends DateFilterStore {
function getMetadata( function getMetadata(
plugin: BookTrackerPlugin, plugin: BookTrackerPlugin,
settings: BookTrackerPluginSettings, settings: BookTrackerPluginSettings,
state: ReadingState state: ReadingState | null
): FileMetadata[] { ): FileMetadata[] {
const metadata: FileMetadata[] = []; const metadata: FileMetadata[] = [];
for (const file of plugin.app.vault.getMarkdownFiles()) { for (const file of plugin.app.vault.getMarkdownFiles()) {
const frontmatter = const frontmatter =
plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {}; plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? {};
if (frontmatter[settings.statusProperty] !== state) { if (
!(settings.statusProperty in frontmatter) ||
(state !== null && frontmatter[settings.statusProperty] !== state)
) {
continue; continue;
} }
@ -44,10 +51,15 @@ function getMetadata(
export function createMetadata( export function createMetadata(
plugin: BookTrackerPlugin, plugin: BookTrackerPlugin,
statusFilter: ReadingState = STATUS_READ, statusFilter: ReadingState | null = STATUS_READ,
initialMonth?: boolean initialMonth?: boolean
): MetadataStore { ): MetadataStore {
const settingsStore = getSettingsContext(); let settingsStore = getSettingsContext();
if (!settingsStore) {
settingsStore = createSettings(plugin);
setSettingsContext(settingsStore);
}
const initialMetadata = getMetadata( const initialMetadata = getMetadata(
plugin, plugin,
settingsStore.settings, settingsStore.settings,

View File

@ -0,0 +1,20 @@
export interface PreviousState<T> {
get value(): T | undefined;
}
export function createPrevious<T>(getter: () => T): PreviousState<T> {
let previous: T | undefined = $state();
let current: T = $state(getter());
$effect(() => {
const newValue = getter();
previous = current;
current = newValue;
});
return {
get value() {
return previous;
},
};
}

View File

@ -8,6 +8,7 @@ export interface ReadingLogStore extends DateFilterStore {
addEntry(entry: ReadingLogEntry): Promise<void>; addEntry(entry: ReadingLogEntry): Promise<void>;
updateEntry(entry: ReadingLogEntry): Promise<void>; updateEntry(entry: ReadingLogEntry): Promise<void>;
removeEntry(entry: ReadingLogEntry): Promise<void>; removeEntry(entry: ReadingLogEntry): Promise<void>;
load(): Promise<void>;
destroy(): void; destroy(): void;
} }
@ -91,6 +92,9 @@ export function createReadingLog(readingLog: ReadingLog): ReadingLogStore {
addEntry, addEntry,
updateEntry, updateEntry,
removeEntry, removeEntry,
async load() {
await readingLog.load();
},
destroy() { destroy() {
loadHandler.off(); loadHandler.off();
createdHandler.off(); createdHandler.off();

View File

@ -19,6 +19,8 @@ interface ReadingLogEventMap {
removed: { entry: ReadingLogEntry }; removed: { entry: ReadingLogEntry };
} }
const DEFAULT_FILENAME = "reading-log.json";
export class ReadingLog extends EventEmitter<ReadingLogEventMap> { export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
private entries: ReadingLogEntry[] = []; private entries: ReadingLogEntry[] = [];
@ -28,9 +30,17 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
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);
}); });
storage.on("change", ({ path }) => {
if (path === DEFAULT_FILENAME) {
this.load().catch((error) => {
console.error("Failed to load reading log entries:", error);
});
}
});
} }
async load(filename = "reading-log.json") { async load(filename = DEFAULT_FILENAME) {
const entries = await this.storage.readJSON<ReadingLogEntry[]>( const entries = await this.storage.readJSON<ReadingLogEntry[]>(
filename filename
); );
@ -50,7 +60,7 @@ export class ReadingLog extends EventEmitter<ReadingLogEventMap> {
); );
} }
async save(filename = "reading-log.json") { async save(filename = DEFAULT_FILENAME) {
this.sortEntries(); this.sortEntries();
await this.storage.writeJSON( await this.storage.writeJSON(
filename, filename,

View File

@ -1,13 +1,23 @@
import BookTrackerPlugin from "@src/main"; import BookTrackerPlugin from "@src/main";
import { App, normalizePath } from "obsidian"; import { App, normalizePath } from "obsidian";
import { EventEmitter } from "./event";
export class Storage { interface StorageEventMap {
public constructor( change: { path: string };
private readonly app: App, }
private readonly plugin: BookTrackerPlugin
) {}
export class Storage extends EventEmitter<StorageEventMap> {
private readonly app: App = this.plugin.app;
private readonly baseDir = this.plugin.manifest.dir!; private readonly baseDir = this.plugin.manifest.dir!;
public constructor(private readonly plugin: BookTrackerPlugin) {
super();
plugin.registerEvent(
// @ts-expect-error "raw" event is an internal api
this.app.vault.on("raw", this.fileChangeHandler.bind(this))
);
}
private getFilePath(filename: string): string { private getFilePath(filename: string): string {
return normalizePath(`${this.baseDir}/${filename}`.replace(/\/$/, "")); return normalizePath(`${this.baseDir}/${filename}`.replace(/\/$/, ""));
} }
@ -44,4 +54,12 @@ export class Storage {
const files = await this.app.vault.adapter.list(this.baseDir); const files = await this.app.vault.adapter.list(this.baseDir);
return files.folders; return files.folders;
} }
private fileChangeHandler(path: string) {
if (!path.startsWith(this.baseDir)) return;
path = path.replace(this.baseDir + "/", "");
this.emit("change", { path });
}
} }