Add reading log backups

This commit is contained in:
Evan Fiordeliso 2025-06-30 21:05:29 -04:00
parent 3d3e62e1ba
commit 868b7d8cff
8 changed files with 168 additions and 77 deletions

View File

@ -0,0 +1,15 @@
import type { ReadingLog } from "@utils/ReadingLog";
import { Command } from "./Command";
export class BackupReadingLogCommand extends Command {
constructor(private readonly readingLog: ReadingLog) {
super("create-reading-log-backup", "Create Reading Log Backup");
}
async callback() {
// @ts-expect-error Moment is provided by Obsidian
const timestamp = moment().format("YYYY-MM-DD_HH-mm-ss");
const backupFilename = `reading-log-backup_${timestamp}.json`;
await this.readingLog.save(backupFilename);
}
}

View File

@ -0,0 +1,41 @@
import type { ReadingLog } from "@utils/ReadingLog";
import { Command } from "./Command";
import type { Storage } from "@utils/Storage";
import { Notice, type App } from "obsidian";
import { TextSuggestModal } from "@ui/modals/TextSuggestModal";
function basename(path: string) {
return path.split("/").pop()!;
}
export class RestoreReadingLogBackupCommand extends Command {
constructor(
private readonly app: App,
private readonly storage: Storage,
private readonly readingLog: ReadingLog
) {
super("restore-reading-log-backup", "Restore Reading Log Backup");
}
async callback() {
let items = await this.storage.listFiles();
items = items
.map((f) => basename(f))
.filter(
(f) =>
f.startsWith("reading-log-backup_") && f.endsWith(".json")
);
const backupPath = await TextSuggestModal.createAndOpen(
this.app,
items
);
if (backupPath) {
await this.readingLog.load(backupPath);
await this.readingLog.save();
} else {
new Notice("No backup file selected.");
}
}
}

View File

@ -15,6 +15,8 @@ import { LogReadingStartedCommand } from "@commands/LogReadingStartedCommand";
import { LogReadingProgressCommand } from "@commands/LogReadingProgressCommand";
import { LogReadingFinishedCommand } from "@commands/LogReadingFinishedCommand";
import { ResetReadingStatusCommand } from "@commands/ResetReadingStatusCommand";
import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
export default class BookTrackerPlugin extends Plugin {
settings: BookTrackerPluginSettings;
@ -54,6 +56,14 @@ export default class BookTrackerPlugin extends Plugin {
this.settings
)
);
this.addCommand(new BackupReadingLogCommand(this.readingLog));
this.addCommand(
new RestoreReadingLogBackupCommand(
this.app,
this.storage,
this.readingLog
)
);
this.addSettingTab(new BookTrackerSettingTab(this));

View File

@ -122,61 +122,45 @@
await onChange?.(query);
}
onMount(() => {
const scope = new Scope();
function onkeydown(event: KeyboardEvent) {
if (!expanded) return;
const arrowUpHandler = scope.register(
[],
"ArrowUp",
(event: KeyboardEvent) => {
if (!event.isComposing) {
setSelectedItem(selectedIndex - 1, true);
return false;
}
},
);
const arrowDownHandler = scope.register(
[],
"ArrowDown",
(event: KeyboardEvent) => {
if (!event.isComposing) {
setSelectedItem(selectedIndex + 1, true);
return false;
}
},
);
const enterHandler = scope.register([], "Enter", (ev) => {
if (!ev.isComposing) {
selectItem(selectedIndex);
return false;
}
});
const escapeHandler = scope.register([], "Escape", (ev) => {
if (!ev.isComposing) {
switch (event.key) {
case "Esc":
case "Escape":
case "Cancel":
expanded = false;
return false;
break;
case "Accept":
case "Enter":
selectItem(selectedIndex);
break;
case "ArrowUp":
setSelectedItem(selectedIndex - 1, true);
break;
case "ArrowDown":
setSelectedItem(selectedIndex + 1, true);
break;
case "Tab":
setSelectedItem(
selectedIndex + (event.shiftKey ? -1 : 1),
false,
);
break;
default:
return;
}
});
app.keymap.pushScope(scope);
return () => {
scope.unregister(arrowUpHandler);
scope.unregister(arrowDownHandler);
scope.unregister(enterHandler);
scope.unregister(escapeHandler);
app.keymap.popScope(scope);
};
});
event.preventDefault();
event.stopPropagation();
}
</script>
<div
class:is-loading={loading}
aria-busy={loading}
use:clickOutside={() => (expanded = false)}
onfocusout={() => (expanded = false)}
>
<div class="search-input-container">
<input
@ -203,20 +187,14 @@
></div>
</div>
{#if expanded}
<div class="suggestion-container" use:popperContent>
<ul
id={`${id}-list`}
bind:this={listEl}
role="listbox"
class="suggestion"
>
<div id={`${id}-list`} class="suggestion-container" use:popperContent>
<ul bind:this={listEl} role="listbox" class="suggestion">
{#each items as item, index}
<li
class="suggestion-item"
class:is-selected={index === selectedIndex}
onclick={() => selectItem(index)}
onkeydown={(event) =>
event.key === "Enter" && selectItem(index)}
{onkeydown}
onmouseover={() => (selectedIndex = index)}
onfocus={() => (selectedIndex = index)}
role="option"

View File

@ -7,7 +7,7 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
constructor(
app: App,
private readonly results: SearchResult[],
private readonly onChoose: (error: any, results: SearchResult[]) => void
private readonly onChoose: (results: SearchResult) => void
) {
super(app);
}
@ -27,7 +27,7 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
item: SearchResult,
evt: MouseEvent | KeyboardEvent
): void {
this.onChoose(null, [item]);
this.onChoose(item);
}
static async createAndOpen(
@ -38,13 +38,9 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
const modal = new GoodreadsSearchSuggestModal(
app,
results,
(error, results) => {
if (error) {
new Notice(`Error: ${error.message}`);
reject(error);
} else {
resolve(results[0]);
}
(results) => {
modal.close();
resolve(results);
}
);
modal.open();

View File

@ -0,0 +1,38 @@
import { App, SuggestModal } from "obsidian";
export class TextSuggestModal extends SuggestModal<string> {
constructor(
app: App,
private readonly items: string[],
private readonly onChoose: (value: string) => void
) {
super(app);
}
getSuggestions(query: string): string[] | Promise<string[]> {
return this.items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}
renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
onChooseSuggestion(item: string, evt: MouseEvent | KeyboardEvent): void {
this.onChoose(item);
}
static async createAndOpen(
app: App,
results: string[]
): Promise<string | undefined> {
return new Promise((resolve, reject) => {
const modal = new TextSuggestModal(app, results, (results) => {
modal.close();
resolve(results);
});
modal.open();
});
}
}

View File

@ -12,14 +12,14 @@ export class ReadingLog {
private entries: ReadingLogEntry[] = [];
public constructor(private readonly storage: Storage) {
this.loadEntries().catch((error) => {
this.load().catch((error) => {
console.error("Failed to load reading log entries:", error);
});
}
private async loadEntries() {
async load(filename = "reading-log.json") {
const entries = await this.storage.readJSON<ReadingLogEntry[]>(
"reading-log.json"
filename
);
if (entries) {
this.entries = entries.map((entry) => ({
@ -35,9 +35,9 @@ export class ReadingLog {
);
}
private async storeEntries() {
async save(filename = "reading-log.json") {
this.sortEntries();
await this.storage.writeJSON("reading-log.json", this.entries);
await this.storage.writeJSON(filename, this.entries);
}
public getEntries(): ReadingLogEntry[] {
@ -76,12 +76,12 @@ export class ReadingLog {
public async addRawEntry(entry: ReadingLogEntry) {
this.entries.push(entry);
await this.storeEntries();
await this.save();
}
public async removeEntries(book: string): Promise<void> {
this.entries = this.entries.filter((entry) => entry.book !== book);
await this.storeEntries();
await this.save();
}
public async removeLastEntry(book: string): Promise<void> {
@ -91,23 +91,23 @@ export class ReadingLog {
if (latestEntryIndex !== -1) {
this.entries.splice(latestEntryIndex, 1);
await this.storeEntries();
await this.save();
}
}
public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> {
this.entries[i] = entry;
await this.storeEntries();
await this.save();
}
public async spliceEntry(i: number): Promise<ReadingLogEntry> {
const entry = this.entries.splice(i, 1);
await this.storeEntries();
await this.save();
return entry[0];
}
public async clearEntries(): Promise<void> {
this.entries = [];
await this.storeEntries();
await this.save();
}
}

View File

@ -1,5 +1,5 @@
import BookTrackerPlugin from "@src/main";
import { App } from "obsidian";
import { App, normalizePath } from "obsidian";
export class Storage {
public constructor(
@ -7,8 +7,9 @@ export class Storage {
private readonly plugin: BookTrackerPlugin
) {}
private readonly baseDir = this.plugin.manifest.dir!!;
private getFilePath(filename: string): string {
return `${this.plugin.manifest.dir!!}/${filename}`;
return normalizePath(`${this.baseDir}/${filename}`.replace(/\/$/, ""));
}
public async readJSON<T>(filename: string): Promise<T | null> {
@ -31,4 +32,16 @@ export class Storage {
const content = JSON.stringify(data, null, 2);
await this.app.vault.adapter.write(filePath, content);
}
public async listFiles(subdir: string = ""): Promise<string[]> {
const files = await this.app.vault.adapter.list(
this.getFilePath(subdir)
);
return files.files;
}
public async listFolders(): Promise<string[]> {
const files = await this.app.vault.adapter.list(this.baseDir);
return files.folders;
}
}