mirror of
https://github.com/mntno/obsidian-come-through.git
synced 2026-07-22 05:47:56 +00:00
0.5.1
This commit is contained in:
parent
9b0f0679f3
commit
199e017bfe
14 changed files with 410 additions and 127 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const devLogger = isDev ? console : noopLogger;
|
|||
|
||||
export const Env = {
|
||||
isDev: !isProduction,
|
||||
dev: isProduction ? () => { } : (action: () => void) => action(),
|
||||
|
||||
/** Always logs */
|
||||
error: console.error,
|
||||
|
|
|
|||
167
src/main.ts
167
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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue