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