This commit is contained in:
Mantano 2025-08-11 17:40:35 +07:00
parent 9b0f0679f3
commit 199e017bfe
14 changed files with 410 additions and 127 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -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<string, SchedulerSetting>;
}
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<void>;
public save: (changedInfo?: SettingsChangedInfo) => Promise<void>;
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<void>) {
public constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise<void>, 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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -14,6 +14,7 @@ const devLogger = isDev ? console : noopLogger;
export const Env = {
isDev: !isProduction,
dev: isProduction ? () => { } : (action: () => void) => action(),
/** Always logs */
error: console.error,

View file

@ -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<PluginData> {
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;
}
}

View file

@ -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(),

View file

@ -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) => `<span lang="th">${match}</span>`);
}
}
/**
* - Add `|x` to match on more elements.
* - Matches both self-closing tags (with a <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;

View file

@ -341,6 +341,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
} else {
this.viewAssistant.createPara({ text: `Unexpected error` });
}
Env.error(error);
}
private async rate(id: FullID, grade: Grade) {

View file

@ -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;
}

View file

@ -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",