generated from tpl/obsidian-sample-plugin
Add reading log backups
This commit is contained in:
parent
3d3e62e1ba
commit
868b7d8cff
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
10
src/main.ts
10
src/main.ts
|
@ -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));
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue