generated from tpl/obsidian-sample-plugin
Add reading log functionality
This commit is contained in:
parent
785ca0e884
commit
35ed9b95ee
|
@ -1,3 +1,4 @@
|
||||||
@use "views/goodreads-search.scss";
|
@use "views/goodreads-search.scss";
|
||||||
@use "views/goodreads-search-suggest.scss";
|
@use "views/goodreads-search-suggest.scss";
|
||||||
|
@use "views/reading-progress.scss";
|
||||||
@use "settings.scss";
|
@use "settings.scss";
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
.obt-reading-progress {
|
||||||
|
&__desc {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: var(--text-ui-smaller);
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
padding-bottom: 18px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pct {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const TO_BE_READ_STATE = "To Be Read";
|
||||||
|
export const IN_PROGRESS_STATE = "Currently Reading";
|
||||||
|
export const READ_STATE = "Read";
|
||||||
|
|
||||||
|
export const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
};
|
204
src/main.ts
204
src/main.ts
|
@ -8,22 +8,27 @@ import { getBookByLegacyId } from "@data-sources/goodreads";
|
||||||
import { Templater } from "./utils/templater";
|
import { Templater } from "./utils/templater";
|
||||||
import { GoodreadsSearchModal } from "@views/goodreads-search-modal";
|
import { GoodreadsSearchModal } from "@views/goodreads-search-modal";
|
||||||
import { GoodreadsSearchSuggestModal } from "@views/goodreads-search-suggest-modal";
|
import { GoodreadsSearchSuggestModal } from "@views/goodreads-search-suggest-modal";
|
||||||
|
import {
|
||||||
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
CONTENT_TYPE_EXTENSIONS,
|
||||||
"image/jpeg": "jpg",
|
IN_PROGRESS_STATE,
|
||||||
"image/png": "png",
|
READ_STATE,
|
||||||
"image/gif": "gif",
|
TO_BE_READ_STATE,
|
||||||
"image/webp": "webp",
|
} from "./const";
|
||||||
};
|
import { ReadingLog, Storage } from "@utils/storage";
|
||||||
|
import { ReadingProgressModal } from "@views/reading-progress-modal";
|
||||||
|
|
||||||
export default class BookTrackerPlugin extends Plugin {
|
export default class BookTrackerPlugin extends Plugin {
|
||||||
settings: BookTrackerPluginSettings;
|
settings: BookTrackerPluginSettings;
|
||||||
templater: Templater;
|
templater: Templater;
|
||||||
|
storage: Storage;
|
||||||
|
readingLog: ReadingLog;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
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.readingLog = new ReadingLog(this.app, this);
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "search-goodreads",
|
id: "search-goodreads",
|
||||||
|
@ -31,6 +36,30 @@ export default class BookTrackerPlugin extends Plugin {
|
||||||
callback: () => this.searchGoodreads(),
|
callback: () => this.searchGoodreads(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "log-reading-started",
|
||||||
|
name: "Log Reading Started",
|
||||||
|
callback: () => this.logReadingStarted(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "log-reading-progress",
|
||||||
|
name: "Log Reading Progress",
|
||||||
|
callback: () => this.logReadingProgress(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "log-reading-completed",
|
||||||
|
name: "Log Reading Completed",
|
||||||
|
callback: () => this.logReadingFinished(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: "reset-reading-status",
|
||||||
|
name: "Reset Reading Status",
|
||||||
|
callback: () => this.resetReadingStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
this.addSettingTab(new BookTrackerSettingTab(this.app, this));
|
this.addSettingTab(new BookTrackerSettingTab(this.app, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,4 +166,165 @@ export default class BookTrackerPlugin extends Plugin {
|
||||||
new Notice("No book selected.");
|
new Notice("No book selected.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logReadingStarted(): void {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new Notice("No active file to mark as currently reading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFile.extension !== "md") {
|
||||||
|
new Notice("Active file is not a markdown file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.statusProperty) {
|
||||||
|
new Notice("Status property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.startDateProperty) {
|
||||||
|
new Notice("Start date property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
|
const startDate = moment().format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => {
|
||||||
|
frontMatter[this.settings.statusProperty] = IN_PROGRESS_STATE;
|
||||||
|
frontMatter[this.settings.startDateProperty] = startDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Notice("Reading started for " + activeFile.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logReadingProgress() {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new Notice("No active file to log reading progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFile.extension !== "md") {
|
||||||
|
new Notice("Active file is not a markdown file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = activeFile.basename;
|
||||||
|
if (!this.settings.pageLengthProperty) {
|
||||||
|
new Notice("Page length property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageLength =
|
||||||
|
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
|
||||||
|
this.settings.pageLengthProperty
|
||||||
|
] as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
if (pageLength <= 0) {
|
||||||
|
new Notice(
|
||||||
|
"Page length property is not set or is invalid in the active file."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumber = await ReadingProgressModal.createAndOpen(this.app);
|
||||||
|
|
||||||
|
if (pageNumber <= 0 || pageNumber > pageLength) {
|
||||||
|
new Notice(
|
||||||
|
`Invalid page number: ${pageNumber}. It must be between 1 and ${pageLength}.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.readingLog.addEntry(fileName, pageNumber);
|
||||||
|
new Notice(
|
||||||
|
`Logged reading progress for ${fileName}: Page ${pageNumber} of ${pageLength}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logReadingFinished() {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new Notice("No active file to mark as finished reading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFile.extension !== "md") {
|
||||||
|
new Notice("Active file is not a markdown file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.statusProperty) {
|
||||||
|
new Notice("Status property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.endDateProperty) {
|
||||||
|
new Notice("End date property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageLength =
|
||||||
|
(this.app.metadataCache.getFileCache(activeFile)?.frontmatter?.[
|
||||||
|
this.settings.pageLengthProperty
|
||||||
|
] as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
if (pageLength <= 0) {
|
||||||
|
new Notice(
|
||||||
|
"Page length property is not set or is invalid in the active file."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.readingLog.addEntry(activeFile.basename, pageLength);
|
||||||
|
|
||||||
|
// @ts-expect-error Moment is provided by Obsidian
|
||||||
|
const endDate = moment().format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => {
|
||||||
|
frontMatter[this.settings.statusProperty] = READ_STATE;
|
||||||
|
frontMatter[this.settings.endDateProperty] = endDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
new Notice("Reading finished for " + activeFile.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetReadingStatus(): any {
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
if (!activeFile) {
|
||||||
|
new Notice("No active file to reset reading status.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFile.extension !== "md") {
|
||||||
|
new Notice("Active file is not a markdown file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.statusProperty) {
|
||||||
|
new Notice("Status property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.startDateProperty) {
|
||||||
|
new Notice("Start date property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.settings.endDateProperty) {
|
||||||
|
new Notice("End date property is not set in settings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app.fileManager.processFrontMatter(activeFile, (frontMatter) => {
|
||||||
|
frontMatter[this.settings.statusProperty] = TO_BE_READ_STATE;
|
||||||
|
frontMatter[this.settings.startDateProperty] = "";
|
||||||
|
frontMatter[this.settings.endDateProperty] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
new Notice("Reading status reset for " + activeFile.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import BookTrackerPlugin from "@src/main";
|
||||||
import { App, PluginSettingTab, Setting } from "obsidian";
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
import { FileSuggest } from "./suggesters/file";
|
import { FileSuggest } from "./suggesters/file";
|
||||||
import { FolderSuggest } from "./suggesters/folder";
|
import { FolderSuggest } from "./suggesters/folder";
|
||||||
|
import { FieldSuggest } from "./suggesters/field";
|
||||||
|
|
||||||
export interface BookTrackerPluginSettings {
|
export interface BookTrackerPluginSettings {
|
||||||
templateFile: string;
|
templateFile: string;
|
||||||
|
@ -11,6 +12,12 @@ export interface BookTrackerPluginSettings {
|
||||||
coverDirectory: string;
|
coverDirectory: string;
|
||||||
groupCoversByFirstLetter: boolean;
|
groupCoversByFirstLetter: boolean;
|
||||||
overwriteExistingCovers: boolean;
|
overwriteExistingCovers: boolean;
|
||||||
|
statusProperty: string;
|
||||||
|
startDateProperty: string;
|
||||||
|
endDateProperty: string;
|
||||||
|
ratingProperty: string;
|
||||||
|
pageLengthProperty: string;
|
||||||
|
readingLogDirectory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: BookTrackerPluginSettings = {
|
export const DEFAULT_SETTINGS: BookTrackerPluginSettings = {
|
||||||
|
@ -21,6 +28,12 @@ export const DEFAULT_SETTINGS: BookTrackerPluginSettings = {
|
||||||
coverDirectory: "images/covers",
|
coverDirectory: "images/covers",
|
||||||
groupCoversByFirstLetter: true,
|
groupCoversByFirstLetter: true,
|
||||||
overwriteExistingCovers: false,
|
overwriteExistingCovers: false,
|
||||||
|
statusProperty: "status",
|
||||||
|
startDateProperty: "startDate",
|
||||||
|
endDateProperty: "endDate",
|
||||||
|
ratingProperty: "rating",
|
||||||
|
pageLengthProperty: "pageLength",
|
||||||
|
readingLogDirectory: "reading-logs",
|
||||||
};
|
};
|
||||||
|
|
||||||
export class BookTrackerSettingTab extends PluginSettingTab {
|
export class BookTrackerSettingTab extends PluginSettingTab {
|
||||||
|
@ -48,6 +61,142 @@ export class BookTrackerSettingTab extends PluginSettingTab {
|
||||||
this.coverDirectorySetting();
|
this.coverDirectorySetting();
|
||||||
this.groupCoversByFirstLetterSetting();
|
this.groupCoversByFirstLetterSetting();
|
||||||
this.overwriteExistingCoversSetting();
|
this.overwriteExistingCoversSetting();
|
||||||
|
|
||||||
|
this.heading("Reading Progress Settings");
|
||||||
|
this.statusPropertySetting();
|
||||||
|
this.startDatePropertySetting();
|
||||||
|
this.endDatePropertySetting();
|
||||||
|
this.ratingPropertySetting();
|
||||||
|
this.pageLengthPropertySetting();
|
||||||
|
this.readingLogDirectorySetting();
|
||||||
|
}
|
||||||
|
|
||||||
|
readingLogDirectorySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("Reading Log Directory")
|
||||||
|
.setDesc("Select the directory where reading logs will be stored")
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FolderSuggest(this.app, cb.inputEl);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("reading-logs")
|
||||||
|
.setValue(this.plugin.settings.readingLogDirectory)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.readingLogDirectory = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLengthPropertySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("Page Length Property")
|
||||||
|
.setDesc(
|
||||||
|
"Property used to track the total number of pages in a book."
|
||||||
|
)
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FieldSuggest(this.app, cb.inputEl, ["number"]);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("pageLength")
|
||||||
|
.setValue(this.plugin.settings.pageLengthProperty)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.pageLengthProperty = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ratingPropertySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("Rating Property")
|
||||||
|
.setDesc("Property used to track the rating of a book.")
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FieldSuggest(this.app, cb.inputEl, ["number"]);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("rating")
|
||||||
|
.setValue(this.plugin.settings.ratingProperty)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.ratingProperty = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
endDatePropertySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("End Date Property")
|
||||||
|
.setDesc("Property used to track the end date of reading a book.")
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FieldSuggest(this.app, cb.inputEl, ["date"]);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("endDate")
|
||||||
|
.setValue(this.plugin.settings.endDateProperty)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.endDateProperty = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startDatePropertySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("Start Date Property")
|
||||||
|
.setDesc("Property used to track the start date of reading a book.")
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FieldSuggest(this.app, cb.inputEl, ["date"]);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("startDate")
|
||||||
|
.setValue(this.plugin.settings.startDateProperty)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.startDateProperty = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
statusPropertySetting() {
|
||||||
|
return new Setting(this.containerEl)
|
||||||
|
.setName("Status Property")
|
||||||
|
.setDesc("Property used to track the reading status of a book.")
|
||||||
|
.addSearch((cb) => {
|
||||||
|
try {
|
||||||
|
new FieldSuggest(this.app, cb.inputEl, ["text"]);
|
||||||
|
} catch {
|
||||||
|
// If the suggest fails, we can just ignore it.
|
||||||
|
// This might happen if the plugin is not fully loaded yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.setPlaceholder("status")
|
||||||
|
.setValue(this.plugin.settings.statusProperty)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.statusProperty = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
overwriteExistingCoversSetting() {
|
overwriteExistingCoversSetting() {
|
||||||
|
|
|
@ -139,9 +139,12 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChanged(): void {
|
async onInputChanged(): Promise<void> {
|
||||||
const inputStr = this.inputEl.value;
|
const inputStr = this.inputEl.value;
|
||||||
const suggestions = this.getSuggestions(inputStr);
|
let suggestions = this.getSuggestions(inputStr);
|
||||||
|
if (suggestions instanceof Promise) {
|
||||||
|
suggestions = await suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
if (!suggestions) {
|
if (!suggestions) {
|
||||||
this.close();
|
this.close();
|
||||||
|
@ -196,7 +199,7 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
|
||||||
this.suggestEl.detach();
|
this.suggestEl.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract getSuggestions(inputStr: string): T[];
|
abstract getSuggestions(inputStr: string): T[] | Promise<T[]>;
|
||||||
abstract renderSuggestion(item: T, el: HTMLElement): void;
|
abstract renderSuggestion(item: T, el: HTMLElement): void;
|
||||||
abstract selectSuggestion(item: T): void;
|
abstract selectSuggestion(item: T): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { App } from "obsidian";
|
||||||
|
import { TextInputSuggest } from "./core";
|
||||||
|
|
||||||
|
export class FieldSuggest extends TextInputSuggest<string> {
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
inputEl: HTMLInputElement,
|
||||||
|
private readonly accepts?: string[]
|
||||||
|
) {
|
||||||
|
super(app, inputEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSuggestions(inputStr: string): Promise<string[]> {
|
||||||
|
const typesContent = await this.app.vault.adapter.read(
|
||||||
|
this.app.vault.configDir + "/types.json"
|
||||||
|
);
|
||||||
|
const types = JSON.parse(typesContent).types;
|
||||||
|
|
||||||
|
return Object.entries(types)
|
||||||
|
.filter(([field, type]) => {
|
||||||
|
if (this.accepts && !this.accepts.includes(type as string)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field.toLowerCase().includes(inputStr.toLowerCase());
|
||||||
|
})
|
||||||
|
.map(([field, _]) => field);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestion(field: string, el: HTMLElement): void {
|
||||||
|
el.setText(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSuggestion(field: string): void {
|
||||||
|
this.inputEl.value = field;
|
||||||
|
this.inputEl.trigger("input");
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
import BookTrackerPlugin from "@src/main";
|
||||||
|
import { App } from "obsidian";
|
||||||
|
|
||||||
|
export class Storage {
|
||||||
|
public constructor(
|
||||||
|
private readonly app: App,
|
||||||
|
private readonly plugin: BookTrackerPlugin
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private getFilePath(filename: string): string {
|
||||||
|
return `${this.plugin.manifest.dir!!}/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readJSON<T>(filename: string): Promise<T | null> {
|
||||||
|
const filePath = this.getFilePath(filename);
|
||||||
|
const content = await this.app.vault.adapter.read(filePath);
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing JSON from ${filePath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeJSON<T>(filename: string, data: T): Promise<void> {
|
||||||
|
const filePath = this.getFilePath(filename);
|
||||||
|
const content = JSON.stringify(data, null, 2);
|
||||||
|
await this.app.vault.adapter.write(filePath, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingLogEntry {
|
||||||
|
readonly book: string;
|
||||||
|
readonly pagesRead: number;
|
||||||
|
readonly pagesReadTotal: number;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReadingLog {
|
||||||
|
private entries: ReadingLogEntry[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly app: App,
|
||||||
|
private readonly plugin: BookTrackerPlugin
|
||||||
|
) {
|
||||||
|
this.loadEntries().catch((error) => {
|
||||||
|
console.error("Failed to load reading log entries:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadEntries() {
|
||||||
|
const entries = await this.plugin.storage.readJSON<ReadingLogEntry[]>(
|
||||||
|
"reading-log.json"
|
||||||
|
);
|
||||||
|
if (entries) {
|
||||||
|
this.entries = entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeEntries() {
|
||||||
|
await this.plugin.storage.writeJSON("reading-log.json", this.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLatestEntry(book: string): ReadingLogEntry | null {
|
||||||
|
const entriesForBook = this.entries.filter(
|
||||||
|
(entry) => entry.book === book
|
||||||
|
);
|
||||||
|
|
||||||
|
return entriesForBook.length > 0
|
||||||
|
? entriesForBook[entriesForBook.length - 1]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addEntry(book: string, pageEnded: number): Promise<void> {
|
||||||
|
const latestEntry = this.getLatestEntry(book);
|
||||||
|
|
||||||
|
const newEntry: ReadingLogEntry = {
|
||||||
|
book,
|
||||||
|
pagesRead: latestEntry
|
||||||
|
? pageEnded - latestEntry.pagesReadTotal
|
||||||
|
: pageEnded,
|
||||||
|
pagesReadTotal: pageEnded,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.entries.push(newEntry);
|
||||||
|
await this.storeEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeEntries(book: string): Promise<void> {
|
||||||
|
this.entries = this.entries.filter((entry) => entry.book !== book);
|
||||||
|
await this.storeEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeLastEntry(book: string): Promise<void> {
|
||||||
|
const latestEntryIndex = this.entries.findLastIndex(
|
||||||
|
(entry) => entry.book === book
|
||||||
|
);
|
||||||
|
|
||||||
|
if (latestEntryIndex !== -1) {
|
||||||
|
this.entries.splice(latestEntryIndex, 1);
|
||||||
|
await this.storeEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearEntries(): Promise<void> {
|
||||||
|
this.entries = [];
|
||||||
|
await this.storeEntries();
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ export class GoodreadsSearchModal extends Modal {
|
||||||
async doSearch(): Promise<void> {
|
async doSearch(): Promise<void> {
|
||||||
if (!this.query || this.query.trim() === "") {
|
if (!this.query || this.query.trim() === "") {
|
||||||
this.onSearch(new Error("Search query cannot be empty."), []);
|
this.onSearch(new Error("Search query cannot be empty."), []);
|
||||||
|
this.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +32,8 @@ export class GoodreadsSearchModal extends Modal {
|
||||||
this.onSearch(null, results);
|
this.onSearch(null, results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.onSearch(error, []);
|
this.onSearch(error, []);
|
||||||
|
} finally {
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +65,6 @@ export class GoodreadsSearchModal extends Modal {
|
||||||
} else {
|
} else {
|
||||||
resolve(results);
|
resolve(results);
|
||||||
}
|
}
|
||||||
modal.close();
|
|
||||||
});
|
});
|
||||||
modal.open();
|
modal.open();
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { App, Modal, ToggleComponent } from "obsidian";
|
||||||
|
|
||||||
|
export class ReadingProgressModal extends Modal {
|
||||||
|
private value: number;
|
||||||
|
private percentage: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
private readonly onSubmit: (pageNumber: number) => void
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.classList.add("obt-reading-progress");
|
||||||
|
contentEl.createEl("h2", { text: "Enter Reading Progress" });
|
||||||
|
|
||||||
|
contentEl.createDiv({ cls: "obt-reading-progress__desc" }, (descEl) => {
|
||||||
|
descEl.createEl("p", {
|
||||||
|
text: "Enter the page number or percentage of the book you have read.",
|
||||||
|
});
|
||||||
|
descEl.createEl("p", {
|
||||||
|
text: "You can toggle between page number and percentage input.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputDiv = contentEl.createDiv({
|
||||||
|
cls: "obt-reading-progress__input",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputEl = inputDiv.createEl("input", {
|
||||||
|
type: "number",
|
||||||
|
placeholder: "Page Number",
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener("change", (ev) => {
|
||||||
|
this.value = Math.max(1, parseInt(inputEl.value, 10));
|
||||||
|
(ev.target as HTMLInputElement).value = this.value.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.onSubmit(this.value);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.createDiv({ cls: "obt-reading-progress__pct" }, (pctDiv) => {
|
||||||
|
pctDiv.createEl("label", { text: "Percentage" });
|
||||||
|
pctDiv.createDiv(
|
||||||
|
{ cls: "obt-reading-progress__toggle" },
|
||||||
|
(toggleDiv) => {
|
||||||
|
new ToggleComponent(toggleDiv)
|
||||||
|
.setValue(this.percentage)
|
||||||
|
.onChange((value) => {
|
||||||
|
this.percentage = value;
|
||||||
|
if (value) {
|
||||||
|
inputEl.setAttribute(
|
||||||
|
"placeholder",
|
||||||
|
"Percentage (%)"
|
||||||
|
);
|
||||||
|
inputEl.setAttribute("max", "100");
|
||||||
|
} else {
|
||||||
|
inputEl.setAttribute(
|
||||||
|
"placeholder",
|
||||||
|
"Page Number"
|
||||||
|
);
|
||||||
|
inputEl.removeAttribute("max");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static createAndOpen(app: App): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const modal = new ReadingProgressModal(app, (pageNumber) => {
|
||||||
|
resolve(pageNumber);
|
||||||
|
});
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
26
styles.css
26
styles.css
|
@ -29,6 +29,32 @@
|
||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.obt-reading-progress__desc {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: var(--text-ui-smaller);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.obt-reading-progress__desc p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.obt-reading-progress__input {
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
.obt-reading-progress__input input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.obt-reading-progress__pct {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
.obt-reading-progress__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.obt-settings .search-input-container {
|
.obt-settings .search-input-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue