From 199e017bfe9d8a5b4e315ba88e9661b6089ce0b4 Mon Sep 17 00:00:00 2001 From: Mantano <91400447+mntno@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:40:35 +0700 Subject: [PATCH] 0.5.1 --- THIRD_PARTY_NOTICES | 26 +++ manifest.json | 2 +- src/DataStore.ts | 87 ++++++--- src/Scheduler.ts | 68 ++++--- src/Settings.ts | 73 +++++++- src/SyncManager.ts | 45 +++-- src/TypeAssistant.ts | 15 +- src/env.ts | 1 + src/main.ts | 167 ++++++++++++------ src/renderings/content/ContentRenderer.ts | 2 +- .../content/HtmlElementWrapperProcessor.ts | 45 ++++- src/views/ReviewView.ts | 1 + styles.css | 4 + versions.json | 1 + 14 files changed, 410 insertions(+), 127 deletions(-) diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index 9b1ecb8..e272300 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -29,3 +29,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +License notice for fast-equals +------------------------------ + +https://github.com/planttheidea/fast-equals/blob/master/LICENSE + +MIT License + +Copyright (c) 2017 Tony Quetano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/manifest.json b/manifest.json index 8a249e8..afaf98f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "come-through", "name": "Come Through", - "version": "0.5.0", + "version": "0.5.1", "minAppVersion": "1.8.0", "description": "Drill flashcards using spaced repetition.", "author": "mntno", diff --git a/src/DataStore.ts b/src/DataStore.ts index 7de4474..6748d87 100644 --- a/src/DataStore.ts +++ b/src/DataStore.ts @@ -1,14 +1,12 @@ import { CardID, FullID, NoteID, DeckID, DeckableFullID } from "FullID"; -import { deepEqual } from 'fast-equals'; +import { deepEqual, strictDeepEqual } from 'fast-equals'; import { asNoteID, isDate, isString } from "TypeAssistant"; import { UniqueID } from "UniqueID"; - -//#region Data structure +import { Env } from "env"; export interface DataStoreRoot { decks: DecksData; active: NotesData; - archived: NotesData; removed: RemovedData; } @@ -70,13 +68,9 @@ export interface StatisticsData { /** state */ st: number; /** last_review ISO 8601. */ - lr?: string; + lr: string | null; } -//#endregion - -//#region Cards - export type CardPredicate = (id: FullID, data: CardData) => boolean; export interface CardIDDataTuple { @@ -117,10 +111,6 @@ export class CardAlreadyExistsError extends Error { } } -//#endregion - -//#region Decks - export type DeckPredicate = (deck: DeckIDDataTuple) => boolean; export interface DeckIDDataTuple { @@ -156,8 +146,6 @@ export class DeckEditor { } }; -//#endregion - /** Use with {@link DataStore.registerOnChangedCallback} */ export type DataChanged = (data: DataStoreRoot) => void; @@ -166,7 +154,6 @@ export class DataStore { public static readonly DEFAULT_DATA: DataStoreRoot = { decks: {}, active: {}, - archived: {}, removed: {} }; @@ -639,6 +626,9 @@ export class DataStore { for (const [noteID, removedData] of Object.entries(this.data.removed)) { + if (!removedData.cs) // Object.entries will throw + continue; + if (noteFilter && noteFilter(noteID, removedData) === false) continue; @@ -690,6 +680,9 @@ export class DataStore { */ public syncData(latestIDs: FullID[], inNoteID: NoteID, statisticsFactory: () => StatisticsData) { + Env.log.d(`DataStore:syncData:\n\tinNoteID: ${inNoteID},\n\tlatestIDs: ${latestIDs.map(id => `${id.cardSide}@${id.cardID}`)}`); + Env.dev(() => latestIDs.forEach(id => Env.assert(id.hasNoteID(inNoteID), `Expected all IDs to belong to ${inNoteID}: ${id}`))); + // Latest data const latestSet = new Set(latestIDs.filter(id => id.isFrontSide).map(id => { console.assert(id.hasNoteID(inNoteID)); @@ -751,6 +744,8 @@ export class DataStore { } } + Env.log.d(`\taddedIDs: ${addedIDs}, removedIDs: ${removedIDs}, modifiedIDs: ${modifiedIDs}`); + return { addedIDs, removedIDs, modifiedIDs }; } @@ -759,6 +754,7 @@ export class DataStore { //#region Persisting data public async save() { + Env.log.d(`DataStore:save: dirty: ${this._isDataDirty}`); if (this._isDataDirty) { const purgeRemovedBeforeDate = new Date((new Date()).getTime() - (this.purgeThreshold * 1000)); this.deleteRemovedCards(purgeRemovedBeforeDate); @@ -774,19 +770,53 @@ export class DataStore { private _isDataDirty = false; /** - * @param changedData - * @param action Called if {@link changedData} is not equal to the current in-memory representation. Call `commit` to confirm changes. - */ - public onDataChangedExternally(changedData: DataStoreRoot, action: (currentData: DataStoreRoot, commit: () => void) => void) { - const currentData = this.data; // Save reference - if (!deepEqual(changedData, currentData)) { - action(currentData, () => { - this.data = changedData; + * Checks whether {@link changedData} differs from the current in-memory data, in which case the {@link changed} callback is called. + * + * It is the callers reponsibility to decide whether the current in-memory data should be overwritten with the {@link changedData} + * by calling the `commit` function passed with the {@link changed} parameter. + * + * @param changedData + * @param changed Called if {@link changedData} is not equal to the current in-memory data. Call `commit` to overwrite the current data with {@link changedData}. + * @param unchanged Called if {@link changedData} is equal to the current in-memory data. + * + * @returns `true` if the {@link changed} callback was invoked. + */ + public onDataChangedExternally(changedData: DataStoreRoot, changed: (info: DataChangedInfo, commit: () => void) => void, unchanged?: () => void) { + const currentData = this.data; + let isNotEqual = false; + + const info: DataChangedInfo = { + currentData: currentData, + changedData: changedData, + collectionsChanged: !deepEqual(changedData.decks, currentData.decks), + activeChanged: !deepEqual(changedData.active, currentData.active), + removedChanged: !deepEqual(changedData.removed, currentData.removed), + }; + + Env.log.d("DataStore:onDataChangedExternally: ", info); + + if (info.collectionsChanged || info.activeChanged || info.removedChanged) { + isNotEqual = true; + changed(info, () => { + this.data = info.changedData; + this.setDataDirty(); this.triggerDataChanged(); }); } + else { + Env.dev(() => { + Env.assert(strictDeepEqual(changedData.decks, currentData.decks), "`collections` are not strictly equal"); + Env.assert(strictDeepEqual(changedData.active, currentData.active), "`active` are not strictly equal"); + Env.assert(strictDeepEqual(changedData.removed, currentData.removed), "`removed` are not strictly equal"); + }); + + unchanged?.(); + } + + return isNotEqual; } + /** Get notified when the in-memory data changed. */ public registerOnChangedCallback(evt: DataChanged) { if (!this.registeredChangedCallbacks.includes(evt)) this.registeredChangedCallbacks.push(evt); @@ -856,6 +886,15 @@ export class DataStore { //#endregion } +/** See {@link DataStore.onDataChangedExternally} */ +export type DataChangedInfo = { + currentData: DataStoreRoot; + changedData: DataStoreRoot; + collectionsChanged: boolean; + activeChanged: boolean; + removedChanged: boolean; +}; + class StatisticsHelper { public static ensureDate(value: DateString | Date) { diff --git a/src/Scheduler.ts b/src/Scheduler.ts index ddf518e..87ff97f 100644 --- a/src/Scheduler.ts +++ b/src/Scheduler.ts @@ -1,28 +1,51 @@ -import { FullID } from 'FullID'; import { CardIDDataTuple, DataStore, StatisticsData } from 'DataStore'; -import { createEmptyCard, fsrs, generatorParameters, Rating, FSRS, Card, State, Grade, TypeConvert, default_request_retention, default_maximum_interval, default_enable_fuzz, default_enable_short_term, default_learning_steps, default_relearning_steps } from 'ts-fsrs'; +import { Env } from 'env'; +import { FullID } from 'FullID'; +import { Card, createEmptyCard, default_enable_fuzz, default_enable_short_term, default_learning_steps, default_maximum_interval, default_relearning_steps, default_request_retention, fsrs, FSRS, generatorParameters, Grade, Rating, State, TypeConvert } from 'ts-fsrs'; +import { isString, toIsoStringOrNull } from 'TypeAssistant'; type DataItem = { id: FullID; card: Card; } +export type FsrsSchedulerConfig = { + enableFuzz: boolean; +} + export class Scheduler { + private static readonly DEFAULT_CONFIG: FsrsSchedulerConfig = { + enableFuzz: default_enable_fuzz, + }; + + private readonly data: DataStore; private fsrs: FSRS; - public constructor(private readonly data: DataStore) { + public constructor(data: DataStore, config: FsrsSchedulerConfig = Scheduler.DEFAULT_CONFIG) { + this.data = data; + this.configure(config); + } - const params = generatorParameters({ - request_retention: default_request_retention, - enable_fuzz: default_enable_fuzz, - enable_short_term: default_enable_short_term, - maximum_interval: default_maximum_interval, - learning_steps: default_learning_steps, - relearning_steps: default_relearning_steps, - // w: default_w, - }); - this.fsrs = fsrs(params); + private static createFsrs(config: FsrsSchedulerConfig) { + Env.assert(config); + config = config ?? Scheduler.DEFAULT_CONFIG; + + const params = generatorParameters({ + request_retention: default_request_retention, + enable_fuzz: config.enableFuzz, + enable_short_term: default_enable_short_term, + maximum_interval: default_maximum_interval, + learning_steps: default_learning_steps, + relearning_steps: default_relearning_steps, + // w: default_w, + }); + + return fsrs(params); + } + + public configure(config: FsrsSchedulerConfig) { + this.fsrs = Scheduler.createFsrs(config); } public createItem() { @@ -274,16 +297,17 @@ export class Scheduler { private static asCard(s: StatisticsData): Card { return { due: TypeConvert.time(s.due), - stability: s.s ?? 0, - difficulty: s.d ?? 0, + stability: s.s, + difficulty: s.d, // TODO: Delete in ts-fsrs 6. Value not used. elapsed_days: 0, - scheduled_days: s.sd ?? 0, - learning_steps: s.ls ?? 0, - reps: s.r ?? 0, - lapses: s.l ?? 0, + scheduled_days: s.sd, + learning_steps: s.ls, + reps: s.r, + lapses: s.l, state: s.st as State, - last_review: s.lr ? TypeConvert.time(s.lr) : undefined, + // Check if is `string` rather than if `null`: Type changed from `string | undefined` to `string | null` in 0.5.1, so there may still be `undefined` values around. `TypeConvert.time` only supports `Date`, `string`, `number`: else throws. + last_review: isString(s.lr) ? TypeConvert.time(s.lr) : undefined, }; } @@ -297,7 +321,7 @@ export class Scheduler { r: card.reps, l: card.lapses, st: card.state, - lr: card.last_review?.toISOString(), + lr: toIsoStringOrNull(card.last_review), } satisfies StatisticsData; } @@ -315,6 +339,6 @@ export class Scheduler { s.r = card.reps; s.l = card.lapses; s.st = card.state; - s.lr = card.last_review?.toISOString(); + s.lr = toIsoStringOrNull(card.last_review); } } diff --git a/src/Settings.ts b/src/Settings.ts index 7f67f42..e8daab4 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -1,5 +1,7 @@ import { PluginSettingTab, Setting, Plugin } from "obsidian"; import { PLUGIN_NAME } from "UIAssistant"; +import { deepEqual } from 'fast-equals'; +import { isString } from "TypeAssistant"; export interface PluginSettings { uiPrefix: string; @@ -7,34 +9,89 @@ export interface PluginSettings { hideDeclarationInReadingView: boolean; /** Defines the time duration in seconds after which "removed" metadata items are eligible for permanent deletion. Items with a "removed date" property older than the current time minus this threshold will be purged. Must be a non-negative integer. */ removedItemsPurgeThreshold: number; + defaultScheduler: string; + schedulers: Record; } +export interface SchedulerConfigSettingItem { + enableFuzz: boolean; +} + +export interface FixedIntervalSchedulerConfigSettingItem extends SchedulerConfigSettingItem { + intervalMin: number; +} + +export interface FsrsSchedulerConfigSettingItem extends SchedulerConfigSettingItem { + reviewSortOrder: string; +} + +export interface FsrsScheduler { + type: "fsrs"; + config: FsrsSchedulerConfigSettingItem; +} + +export interface FixedIntervalScheduler { + type: "fixedInterval"; + config: FixedIntervalSchedulerConfigSettingItem; +} + +export type SchedulerSetting = FsrsScheduler | FixedIntervalScheduler; +const SCHEDULER_ID_DEFUALT = "default"; + export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void; +export type SettingsChangedInfo = "schedulerConfig"; // | "x" + export class SettingsManager { + + /** For reading and writing settings. */ public settings: PluginSettings; /** Saves the {@link settings} to disk. */ - public save: () => Promise; + public save: (changedInfo?: SettingsChangedInfo) => Promise; public static readonly DEFAULT_DATA: PluginSettings = { uiPrefix: PLUGIN_NAME, hideCardSectionMarker: false, hideDeclarationInReadingView: false, removedItemsPurgeThreshold: 24 * 60 * 60, + defaultScheduler: SCHEDULER_ID_DEFUALT, + schedulers: { + [SCHEDULER_ID_DEFUALT]: { + type: "fsrs", + config: { + enableFuzz: true, + reviewSortOrder: "due" + } + } + } }; - public constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise) { + public constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise, onSaved: (changedInfo?: SettingsChangedInfo) => void) { this.settings = settings; - this.save = async () => { + this.save = async (changedInfo?: SettingsChangedInfo) => { await save(this.settings); + onSaved?.(changedInfo); this.notifyOnChangedListeners(false); }; } - public onSettingsChangedExternally(settings: PluginSettings) { - this.settings = settings; - this.notifyOnChangedListeners(true); + /** + * Overwrites the current settings with {@link settings} if they are not equal, in which case the change listerners are invoked. + * + * Note: {@link save} is not called. If method returns `true`, it's the caller's responsibility to persist the new values. + * + * @param settings + * @returns `true` if {@link settings} is not equal to the current settings. + */ + public onSettingsChangedExternally(settings: PluginSettings, changed?: (settings: PluginSettings) => void) { + const isNotEqual = !deepEqual(settings, this.settings); + if (isNotEqual) { + this.settings = settings; + changed?.(this.settings); + this.notifyOnChangedListeners(true); + } + return isNotEqual; } public registerOnChangedCallback(evt: SettingsChanged) { @@ -51,6 +108,10 @@ export class SettingsManager { } private registeredChangedCallbacks: SettingsChanged[] = []; + + public get defaultScheduler(): SchedulerSetting { + return this.settings.schedulers[isString(this.settings.defaultScheduler) ? this.settings.defaultScheduler : SCHEDULER_ID_DEFUALT]; + } } export class SettingTab extends PluginSettingTab { diff --git a/src/SyncManager.ts b/src/SyncManager.ts index f58c538..cb2c6ac 100644 --- a/src/SyncManager.ts +++ b/src/SyncManager.ts @@ -1,17 +1,17 @@ import { DataStore, StatisticsData } from "DataStore"; import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration"; import { DeclarationInfo, DeclarationParser, PostParseInfo } from "declarations/DeclarationParser"; +import { Env } from "env"; import { FullID, NoteID } from "FullID"; import { App, CachedMetadata, Editor, FileManager, TAbstractFile, TFile } from "obsidian"; import { asNoteID, isString } from "TypeAssistant"; - export class SyncManager { public readonly app: App; private readonly dataStore: DataStore; private readonly statisticsFactory: () => StatisticsData; - private isDisabled = false; + private isSuspended = false; public constructor(dataStore: DataStore, app: App, statisticsFactory: () => StatisticsData) { this.dataStore = dataStore; @@ -19,16 +19,33 @@ export class SyncManager { this.statisticsFactory = statisticsFactory; } - public setDisabled() { - this.isDisabled = true; + public suspend() { + this.isSuspended = true; } - public setEnabled() { - this.isDisabled = false; + public resume() { + this.isSuspended = false; + } + + /** Ensures that {@link resume} is called after {@link action} has exited. */ + public whileSuspended(action: () => void) { + Env.log.d("SyncManager:whileSuspended: suspending"); + try { + this.suspend(); + action(); + } + catch (error) { + Env.error(error); + } + finally { + Env.log.d("SyncManager:whileSuspended: resuming"); + this.resume(); + } } public async open(file: TFile | null) { - if (this.isDisabled) + Env.log.d(`SyncManager:open isSuspended: ${this.isSuspended}`); + if (this.isSuspended) return; if (file) { @@ -38,7 +55,8 @@ export class SyncManager { } public async changed(file: TFile, data: string, cache: CachedMetadata) { - if (this.isDisabled) + Env.log.d(`SyncManager:changed isSuspended: ${this.isSuspended}`); + if (this.isSuspended) return; const ids = await SyncManager.processFileChanged(file, data, cache, this.app); @@ -46,7 +64,8 @@ export class SyncManager { } public async delete(file: TAbstractFile) { - if (this.isDisabled) + Env.log.d(`SyncManager:delete isSuspended: ${this.isSuspended}`); + if (this.isSuspended) return; if (file instanceof TFile && this.dataStore.removeNote(asNoteID(file.path))) @@ -54,7 +73,8 @@ export class SyncManager { } public async rename(file: TAbstractFile, oldPath: string) { - if (this.isDisabled) + Env.log.d(`SyncManager:rename isSuspended: ${this.isSuspended}`); + if (this.isSuspended) return; if (file instanceof TFile && this.dataStore.changeNoteID(asNoteID(oldPath), asNoteID(file), false)) @@ -255,7 +275,10 @@ export class SyncManager { } private async syncIDs(ids: FullID[], file: TFile) { - if (this.isDisabled) + Env.log.d(`SyncManager:syncIDs isSuspended: ${this.isSuspended}, num IDs: ${ids.length}`); + if (this.isSuspended) + return; + if (ids.length == 0) return; try { diff --git a/src/TypeAssistant.ts b/src/TypeAssistant.ts index 0d99012..eff2200 100644 --- a/src/TypeAssistant.ts +++ b/src/TypeAssistant.ts @@ -1,8 +1,6 @@ import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration"; import { DeckableFullID, FullID, NoteID } from "FullID"; import { TFile } from "obsidian"; -import { ContentProcessorConfig } from "renderings/content/ContentRendererProcessor"; -import { PluginSettings } from "Settings"; export function asNoteID(value: TFile | string): NoteID { if (value instanceof TFile) @@ -57,3 +55,16 @@ export function parseStrictFloat(value: unknown): number | null { return num; } + +/** + * Values set to `undefined` are invalid JSON. + * If optional dates are stored in JSON files, use this method to make sure any unset properties are serialized and deserialized. + * + * - This is particularly important when diffing two files. + * + * @param date The date to convert. + * @returns The ISO 8601 string representation of the date, or `null` if the date is `undefined`. + */ +export function toIsoStringOrNull(date: Date | undefined) { + return date !== undefined ? date.toISOString() : null +} diff --git a/src/env.ts b/src/env.ts index ac9ad41..f4363ea 100644 --- a/src/env.ts +++ b/src/env.ts @@ -14,6 +14,7 @@ const devLogger = isDev ? console : noopLogger; export const Env = { isDev: !isProduction, + dev: isProduction ? () => { } : (action: () => void) => action(), /** Always logs */ error: console.error, diff --git a/src/main.ts b/src/main.ts index fbdb52c..7777a98 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,19 @@ -import { DeclarationManager } from "declarations/DeclarationManager"; import { DataStore, DataStoreRoot } from "DataStore"; +import { DeclarationManager } from "declarations/DeclarationManager"; import { DeclarationParser } from "declarations/DeclarationParser"; +import { Env } from "env"; +import t from "Localization"; import { ConfirmationModal } from "modals/ConfirmationModal"; import { SelectDeckModal } from "modals/SelectDeckModal"; import { Editor, Keymap, MarkdownPostProcessorContext, MarkdownView, PaneType, Plugin, TFile } from "obsidian"; -import { Scheduler } from "Scheduler"; -import { PluginSettings, SettingsManager, SettingTab } from "Settings"; +import { FsrsSchedulerConfig, Scheduler } from "Scheduler"; +import { PluginSettings, SettingsChangedInfo, SettingsManager, SettingTab } from "Settings"; import { SyncManager } from "SyncManager"; import { PLUGIN_ICON, UIAssistant } from "UIAssistant"; import { UniqueID } from "UniqueID"; import { DecksView } from "views/DecksView"; -import { ReviewView } from "views/ReviewView"; -import t from "Localization"; import { DefinedContentView } from "views/DefinedContentView"; +import { ReviewView } from "views/ReviewView"; interface PluginData { settings: PluginSettings; @@ -20,22 +21,36 @@ interface PluginData { } export default class ComeThroughPlugin extends Plugin { - private pluginData: PluginData; - private settingsManager: SettingsManager; - private scheduler: Scheduler; - private ui: UIAssistant; private dataStore: DataStore; + private scheduler: Scheduler; + private settingsManager: SettingsManager; private syncManager: SyncManager; + private ui: UIAssistant; + + /** Must referene the latest data before {@link savePluginData} is called. Only needed to hold references in order to pass to {@link Plugin.saveData}, see {@link savePluginData}. */ + private latestPluginDataRef: PluginData; public async onload() { - this.pluginData = await ComeThroughPlugin.loadPluginData(this); - this.settingsManager = new SettingsManager(this.pluginData.settings, async (_settings) => await this.savePluginData()); - this.ui = new UIAssistant(this.settingsManager); - this.dataStore = new DataStore(this.pluginData.data, this.pluginData.settings.removedItemsPurgeThreshold, async (_data) => await this.savePluginData()); - this.scheduler = new Scheduler(this.dataStore); - this.syncManager = new SyncManager(this.dataStore, this.app, () => this.scheduler.createItem()); + this.latestPluginDataRef = await ComeThroughPlugin.loadPluginData(this); + this.settingsManager = new SettingsManager( + this.latestPluginDataRef.settings, + async (settings) => { + this.latestPluginDataRef.settings = settings; + await this.savePluginData(); + }, + this.onSettingsSaved.bind(this) + ); + + this.dataStore = new DataStore(this.latestPluginDataRef.data, this.latestPluginDataRef.settings.removedItemsPurgeThreshold, async (data) => { + this.latestPluginDataRef.data = data; + await this.savePluginData(); + }); + + this.scheduler = new Scheduler(this.dataStore, this.createSchedulerConfig()); + this.syncManager = new SyncManager(this.dataStore, this.app, () => this.scheduler.createItem()); + this.ui = new UIAssistant(this.settingsManager); this.addSettingTab(new SettingTab(this, this.settingsManager)); this.addRibbonIcon(PLUGIN_ICON, this.ui.contextulize("Review"), (evt: MouseEvent) => { @@ -96,9 +111,9 @@ export default class ComeThroughPlugin extends Plugin { if (markdownView && markdownView.file && DeclarationParser.containsDeclarations(markdownView.file, this.app)) { if (!checking) this.viewDefinedContent(markdownView.file, false); - return true + return true; } - return false + return false; } }); @@ -116,47 +131,61 @@ export default class ComeThroughPlugin extends Plugin { this.confirmationModal.forceClose(); } - /** - * Handles external changes to the plugin's settings and data. - * This is triggered when the data file is modified by an external source, such as a sync service. - * It disables the internal sync manager, reloads the data, and prompts the user to accept the change. - */ + /** This is triggered when the data file is modified by an external source, such as a sync service. */ public async onExternalSettingsChange() { - if (!this.pluginData) - return; + Env.log.d("Plugin:onExternalSettingsChange"); - this.syncManager.setDisabled(); // Disable as soon as possible before any file events are fired. + const overwrittenDataOnDisk = await ComeThroughPlugin.loadPluginData(this); + this.settingsManager.onSettingsChangedExternally(overwrittenDataOnDisk.settings, (changed) => this.latestPluginDataRef.settings = changed); - this.pluginData = await ComeThroughPlugin.loadPluginData(this); - this.settingsManager.onSettingsChangedExternally(this.pluginData.settings); + this.syncManager.whileSuspended(() => { - this.dataStore.onDataChangedExternally(this.pluginData.data, (currentData: DataStoreRoot, commit: () => void) => { + // Four scenarios: + // 1. Explicit user data changed -> show modal. + // - If user accepts change, refresh in-memory data. + // - If user rejects change, revert the changes on disk. + // 2. Other data changed -> Accept change (as above) without user interaction. + // 3. No changes detected -> Do nothing. - // Prevent multiple modals opening. - if (!this.confirmationModal) { - this.confirmationModal = new ConfirmationModal(this.app); - this.confirmationModal.open(); - } + this.dataStore.onDataChangedExternally( + overwrittenDataOnDisk.data, + (info, commit: () => void) => { - this.confirmationModal.onClosed = () => { - this.confirmationModal = undefined; - } + // Avoid writing to disk again if not needed as this method was already called because of a modification. + // The only time it's needed is when the user rejects the change. + const accept = () => { + this.latestPluginDataRef.data = info.changedData; // Update reference first because data change listeners will be called that might call `save`. + commit(); + }; - this.confirmationModal.onButton1Click = () => { - // Use new data. - // If the data is changed externally several times before the user takes action, the intermediate external changes are ignored. - // No need to persist to disk here because the change has already occured. - commit(); - this.syncManager.setEnabled(); - }; + const reject = () => { + this.latestPluginDataRef.data = info.currentData; + this.savePluginData(); // Note: calling `save` on `DataStore` will be a no-op because data is not dirty. + }; - this.confirmationModal.onButton2Click = () => { - // Reverse change, use old data. - this.pluginData.data = currentData; - this.syncManager.setEnabled(); - this.savePluginData(); - }; + // Only show modal if data explicitly created by the user has changed. Otherwise, automatically accept the changes. + // For example, if the modal is shown when the plugin merely has automatically purged a removed statistics item, the user will be confused because they didn't make any changes/rated. + if (info.activeChanged || info.collectionsChanged) { + // Prevent multiple modals opening (as external changes might occur while the modal is showing). + if (!this.confirmationModal) { + this.confirmationModal = new ConfirmationModal(this.app); + this.confirmationModal.onClosed = () => this.confirmationModal = undefined; + this.confirmationModal.open(); + } + + // Note: Several external changes might occur while the modal is showing. User accepting should apply the latest change. Therefore, overwrite events so that the latest variable values are used. + this.confirmationModal.onButton1Click = () => accept(); + this.confirmationModal.onButton2Click = () => reject(); + } + else { + Env.log.d(`\tAccepting data changes without showing modal.`); + accept(); + } + }, () => { + Env.log.d(`\tNo data changes detected.`) + } + ); }); } private confirmationModal?: ConfirmationModal; @@ -241,17 +270,51 @@ export default class ComeThroughPlugin extends Plugin { private static async loadPluginData(plugin: Plugin): Promise { const data = await plugin.loadData(); // Returns `null` if file doesn't exist. + + // Prepare a temporary settings object by merging top-level properties. + const mergedSettings = { + ...SettingsManager.DEFAULT_DATA, + ...data?.settings || {} + }; + + // Explicitly merge the nested `schedulers` object. + // This combines the default schedulers with any schedulers from the loaded data. + // This only adds the default scheduler object(s) if their keys are missing, but it doesn't go deeper than that, i.e., if a defaukt key is there but some of that objects keys are missing, those missing keys will not be added. + mergedSettings.schedulers = { + ...SettingsManager.DEFAULT_DATA.schedulers, + ...(data?.settings?.schedulers || {}) + }; + return { ...{}, ...{ - settings: { ...SettingsManager.DEFAULT_DATA, ...data?.settings || {} }, - data: { ...DataStore.DEFAULT_DATA, ...data?.data || {} } + settings: mergedSettings, + data: { + ...DataStore.DEFAULT_DATA, + ...data?.data || {} + } } satisfies PluginData }; } + /** Writes {@link latestPluginDataRef} to disk. */ private async savePluginData() { - await this.saveData(this.pluginData); + await this.saveData(this.latestPluginDataRef); } + private onSettingsSaved(changedInfo?: SettingsChangedInfo) { + switch (changedInfo) { + case "schedulerConfig": + this.scheduler.configure(this.createSchedulerConfig()); + break; + } + } + + private createSchedulerConfig() { + const config = this.settingsManager.defaultScheduler.config; + Env.assert(config); + return { + enableFuzz: config.enableFuzz, + } satisfies FsrsSchedulerConfig; + } } diff --git a/src/renderings/content/ContentRenderer.ts b/src/renderings/content/ContentRenderer.ts index fdfed57..0fe9523 100644 --- a/src/renderings/content/ContentRenderer.ts +++ b/src/renderings/content/ContentRenderer.ts @@ -47,7 +47,7 @@ export class ContentRenderer extends RecycleComponent { /** These will always run, and before any custom ones. */ this.defaultProcessors = [ - new HtmlElementWrapperProcessor(), + new HtmlElementWrapperProcessor({ applyLangTagsToNonLatinScripts: false }), new LinkProcessor(), new AudioProcessor(), new VideoProcessor(), diff --git a/src/renderings/content/HtmlElementWrapperProcessor.ts b/src/renderings/content/HtmlElementWrapperProcessor.ts index 624f05b..be88006 100644 --- a/src/renderings/content/HtmlElementWrapperProcessor.ts +++ b/src/renderings/content/HtmlElementWrapperProcessor.ts @@ -1,6 +1,41 @@ import { MarkupAssistant } from "renderings/MarkupAssistant"; import { ContentRendererPreProcessor, ContentRendererProcessor, PreProcessorParameter } from "./ContentRendererProcessor"; + +export type HtmlElementWrapperProcessorConfig = { + applyLangTagsToNonLatinScripts: boolean; +} + +/** + * Processes inline HTML in the markdown string. + */ +export class HtmlElementWrapperProcessor extends ContentRendererProcessor implements ContentRendererPreProcessor { + + private config: HtmlElementWrapperProcessorConfig; + + constructor(config: HtmlElementWrapperProcessorConfig) { + super(); + this.config = config; + } + + public handleMarkdown(param: PreProcessorParameter): void { + let content = param.markdown; + + content = content.replace(ELEMENT_REGEX, (html, tag) => { + return MarkupAssistant.createElWrapperHtml(tag, html); + }); + + if (this.config.applyLangTagsToNonLatinScripts) + content = this.applyLangTagsToNonLatinScripts(content) + + param.markdown = content; + } + + private applyLangTagsToNonLatinScripts(content: string) { + return content.replace(THAI_REGEX, (match) => `${match}`); + } +} + /** * - Add `|x` to match on more elements. * - Matches both self-closing tags (with a ) and and normal closing. @@ -8,12 +43,6 @@ import { ContentRendererPreProcessor, ContentRendererProcessor, PreProcessorPara const ELEMENT_REGEX = /<(audio|video)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1>)/gi; /** - * Processes inline HTML in the markdown string. + * - Matches consecutive Thai characters. */ -export class HtmlElementWrapperProcessor extends ContentRendererProcessor implements ContentRendererPreProcessor { - public handleMarkdown(param: PreProcessorParameter): void { - param.markdown = param.markdown.replace(ELEMENT_REGEX, (html, tag) => { - return MarkupAssistant.createElWrapperHtml(tag, html); - }); - } -} +const THAI_REGEX = /[\u0E00-\u0E7F]+/g; diff --git a/src/views/ReviewView.ts b/src/views/ReviewView.ts index 0d463f2..21b30c1 100644 --- a/src/views/ReviewView.ts +++ b/src/views/ReviewView.ts @@ -341,6 +341,7 @@ export class ReviewView extends BaseView { } else { this.viewAssistant.createPara({ text: `Unexpected error` }); } + Env.error(error); } private async rate(id: FullID, grade: Grade) { diff --git a/styles.css b/styles.css index 8b0efed..4c48c82 100644 --- a/styles.css +++ b/styles.css @@ -155,3 +155,7 @@ div.come-through-workspace-leaf-content[data-type='come-through-view-defined-con font-variant: var(--h2-variant); font-weight: var(--h2-weight); } + +div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] :lang(th) { + font-size: 1.25rem; +} diff --git a/versions.json b/versions.json index c28bf21..d27a119 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { + "0.5.1": "1.8.0", "0.5.0": "1.8.0", "0.4.2": "1.8.0", "0.4.1": "1.8.0",