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

View File

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

View File

@ -7,7 +7,7 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
constructor( constructor(
app: App, app: App,
private readonly results: SearchResult[], private readonly results: SearchResult[],
private readonly onChoose: (error: any, results: SearchResult[]) => void private readonly onChoose: (results: SearchResult) => void
) { ) {
super(app); super(app);
} }
@ -27,7 +27,7 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
item: SearchResult, item: SearchResult,
evt: MouseEvent | KeyboardEvent evt: MouseEvent | KeyboardEvent
): void { ): void {
this.onChoose(null, [item]); this.onChoose(item);
} }
static async createAndOpen( static async createAndOpen(
@ -38,13 +38,9 @@ export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
const modal = new GoodreadsSearchSuggestModal( const modal = new GoodreadsSearchSuggestModal(
app, app,
results, results,
(error, results) => { (results) => {
if (error) { modal.close();
new Notice(`Error: ${error.message}`); resolve(results);
reject(error);
} else {
resolve(results[0]);
}
} }
); );
modal.open(); 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[] = []; private entries: ReadingLogEntry[] = [];
public constructor(private readonly storage: Storage) { public constructor(private readonly storage: Storage) {
this.loadEntries().catch((error) => { this.load().catch((error) => {
console.error("Failed to load reading log entries:", 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[]>( const entries = await this.storage.readJSON<ReadingLogEntry[]>(
"reading-log.json" filename
); );
if (entries) { if (entries) {
this.entries = entries.map((entry) => ({ this.entries = entries.map((entry) => ({
@ -35,9 +35,9 @@ export class ReadingLog {
); );
} }
private async storeEntries() { async save(filename = "reading-log.json") {
this.sortEntries(); this.sortEntries();
await this.storage.writeJSON("reading-log.json", this.entries); await this.storage.writeJSON(filename, this.entries);
} }
public getEntries(): ReadingLogEntry[] { public getEntries(): ReadingLogEntry[] {
@ -76,12 +76,12 @@ export class ReadingLog {
public async addRawEntry(entry: ReadingLogEntry) { public async addRawEntry(entry: ReadingLogEntry) {
this.entries.push(entry); this.entries.push(entry);
await this.storeEntries(); await this.save();
} }
public async removeEntries(book: string): Promise<void> { public async removeEntries(book: string): Promise<void> {
this.entries = this.entries.filter((entry) => entry.book !== book); this.entries = this.entries.filter((entry) => entry.book !== book);
await this.storeEntries(); await this.save();
} }
public async removeLastEntry(book: string): Promise<void> { public async removeLastEntry(book: string): Promise<void> {
@ -91,23 +91,23 @@ export class ReadingLog {
if (latestEntryIndex !== -1) { if (latestEntryIndex !== -1) {
this.entries.splice(latestEntryIndex, 1); this.entries.splice(latestEntryIndex, 1);
await this.storeEntries(); await this.save();
} }
} }
public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> { public async updateEntry(i: number, entry: ReadingLogEntry): Promise<void> {
this.entries[i] = entry; this.entries[i] = entry;
await this.storeEntries(); await this.save();
} }
public async spliceEntry(i: number): Promise<ReadingLogEntry> { public async spliceEntry(i: number): Promise<ReadingLogEntry> {
const entry = this.entries.splice(i, 1); const entry = this.entries.splice(i, 1);
await this.storeEntries(); await this.save();
return entry[0]; return entry[0];
} }
public async clearEntries(): Promise<void> { public async clearEntries(): Promise<void> {
this.entries = []; this.entries = [];
await this.storeEntries(); await this.save();
} }
} }

View File

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