This commit is contained in:
Mantano 2025-12-20 20:06:57 +07:00
parent 199e017bfe
commit c9f3e55df3
90 changed files with 5808 additions and 2039 deletions

20
.gemini/settings.json Normal file
View file

@ -0,0 +1,20 @@
{
"tools": {
"autoAccept": false
},
"context": {
"fileName": [
"AGENTS.md"
],
"fileFiltering": {
"respectGitIgnore": true,
"enableRecursiveFileSearch": true
}
},
"ui": {
"hideBanner": true
},
"privacy": {
"usageStatisticsEnabled": false
}
}

9
.gitignore vendored
View file

@ -1,9 +1,5 @@
# vscode
.vscode
# Intellij
*.iml
.idea
.vscode
# npm
node_modules
@ -22,6 +18,7 @@ data.json
# This plugin
## The compiled main.js file.
main.js
styles.css
/dist/

4
.zed/settings.json Normal file
View file

@ -0,0 +1,4 @@
// Folder-specific settings
{
"project_name": "Come Through"
}

137
AGENTS.md Normal file
View file

@ -0,0 +1,137 @@
# Obsidian community plugin
## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
## Environment & tooling
- Node.js: use current LTS (Node 18+ recommended).
- **Package manager: pnpm** (required for this sample - `package.json` defines pnpm scripts and dependencies).
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
- Types: `obsidian` type definitions.
**Note**: This sample project has specific technical dependencies on pnpm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
### Install
```bash
pnpm install
```
### Dev (watch)
```bash
pnpm run dev
```
### Production build
```bash
pnpm run build
```
## Manifest rules (`manifest.json`)
- Must include (non-exhaustive):
- `id` (plugin ID; for local dev it should match the folder name)
- `name`
- `version` (Semantic Versioning `x.y.z`)
- `minAppVersion`
- `description`
- `isDesktopOnly` (boolean)
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
- Never change `id` after release. Treat it as stable API.
- Keep `minAppVersion` accurate when using newer APIs.
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
## Versioning & releases
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
## UX & copy guidelines (for UI text, commands, settings)
- Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy.
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: **Settings → Community plugins**.
- Keep in-app strings short, consistent, and free of jargon.
## Performance
- Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during `onload`; use lazy initialization.
- Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events.
## Coding conventions
- TypeScript with `"strict": true` preferred.
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
- Bundle everything into `main.js` (no unbundled runtime deps).
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
- Prefer `async/await` over promise chains; handle errors gracefully.
## Mobile
- Where feasible, test on iOS and Android.
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
- Avoid large in-memory structures; be mindful of memory and storage constraints.
## Agent do/don't
**Do**
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use `this.register*` helpers for everything that needs cleanup.
**Don't**
- Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented.
## Common tasks
### Register listeners safely
```ts
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
this.registerDomEvent(window, "resize", () => { /* ... */ });
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
```
## Troubleshooting
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
- Build issues: if `main.js` is missing, run `pnpm run build` or `pnpm run dev` to compile your TypeScript source code.
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
## References
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide

View file

@ -102,11 +102,11 @@ To create decks, select *Decks* in the [Command palette](https://help.obsidian.m
To review the cards, click / tap on *Review* in the [ribbon](https://help.obsidian.md/ribbon) or the [Command palette](https://help.obsidian.md/plugins/command-palette).
## Sync
## Syncing
There are scenarios during synching between devices which may result in your cards review statistics being reset. This often occurs when you make changes on two or more devices before any synchronization happens. For example, if two devices are offline and both have pending changes, a conflict might arise the next time they go online and try to sync, as the sync mechanism won't know which changes to prioritize.
Syncing your vault across multiple devices can sometimes lead to conflicts, which may cause review statistics to be overwritten with older data. This can happen if you, e.g., rate on two or more devices while they are offline or before they have had a chance to sync. When the devices come online, the sync service may not be able to determine which version of the data is the correct one and will have to choose one version. To avoid this, the safest approach is to only make changes or rate on one device at a time.
To prevent this, the safest approach is to make changes on only one device at a time. Once changes on that device are complete, ensure all devices are fully synchronized before initiating changes on a different device.
However, as soon as changes are detected, a dialog is shown where you may accept or reject the change. Note that if you reject on one device, the next sync will cause the dialog to show on the other device(s), where you should then accept the change.
## Roadmap
@ -117,6 +117,3 @@ To prevent this, the safest approach is to make changes on only one device at a
## More
See the [release notes](https://github.com/mntno/obsidian-come-through/releases) for more details.

View file

@ -1,6 +1,7 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import esbuild from "esbuild";
import fs from "fs";
import process from "process";
const banner =
`/*
@ -10,12 +11,17 @@ if you want to view the source, please visit the github repository of this plugi
`;
const prod = (process.argv[2] === "production");
const outdir = prod ? "dist" : ".";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
// When you use an object for entryPoints, the keys become the output filenames and the values are the input file paths.
entryPoints: {
main: "src/main.ts",
styles: "styles/main.css"
},
bundle: true,
external: [
"obsidian",
@ -37,15 +43,16 @@ const context = await esbuild.context({
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
outdir: outdir,
minify: prod,
define: {
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
},
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
},
});
if (prod) {
await context.rebuild();
fs.copyFileSync("manifest.json", "dist/manifest.json");
process.exit(0);
} else {
await context.watch();

67
eslint.config.js Normal file
View file

@ -0,0 +1,67 @@
// https://typescript-eslint.io/packages/typescript-eslint#usage
import eslint from "@eslint/js";
import { defineConfig } from 'eslint/config';
import tseslint from "typescript-eslint";
import globals from "globals";
//import obsidianmd from "eslint-plugin-obsidianmd";
// If using Svelte
// import sveltePlugin from "eslint-plugin-svelte";
// import svelteParser from "svelte-eslint-parser";
export default defineConfig(
{
ignores: [
"**/build/**",
"**/dist/**",
"./main.js",
"./src/**/*js",
],
},
eslint.configs.recommended,
// https://typescript-eslint.io/users/configs#recommended-configurations
...tseslint.configs.recommended,
//...obsidianmd.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
// https://typescript-eslint.io/packages/typescript-eslint#manual-usage
"@typescript-eslint": tseslint.plugin,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: "./tsconfig.json",
projectService: "true",
sourceType: "module",
ecmaVersion: "latest",
},
globals: {
...globals.browser,
...globals.node,
// Instead of adding `obsidian-typings` package. Add whatever is used here instead.
createDiv: "readonly",
}
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", {
args: "none"
}],
"@typescript-eslint/ban-ts-comment": ["error", {
"ts-expect-error": false,
"ts-ignore": true,
"ts-nocheck": true,
"ts-check": true,
}],
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unnecessary-condition": ["warn", {
// https://typescript-eslint.io/rules/no-unnecessary-condition/#only-allowed-literals
"allowConstantLoopConditions": "only-allowed-literals"
}],
},
},
);

View file

@ -1,46 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import typeScriptPlugin from "@typescript-eslint/eslint-plugin";
import typeScriptParser from "@typescript-eslint/parser";
// If using Svelte
// import sveltePlugin from "eslint-plugin-svelte";
// import svelteParser from "svelte-eslint-parser";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": typeScriptPlugin,
},
languageOptions: {
parser: typeScriptParser,
parserOptions: {
project: './tsconfig.json',
sourceType: 'module',
ecmaVersion: "latest",
},
globals: {
...globals.browser,
//...globals.node
}
},
rules: {
...js.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
}
},
{
ignores: [
"**/*js",
"**/node_modules/**",
],
},
];

View file

@ -1,7 +1,7 @@
{
"id": "come-through",
"name": "Come Through",
"version": "0.5.1",
"version": "0.6.0",
"minAppVersion": "1.8.0",
"description": "Drill flashcards using spaced repetition.",
"author": "mntno",

View file

@ -12,19 +12,20 @@
"author": "mntno",
"license": "MIT",
"devDependencies": {
"@eslint/js": "9.21.0",
"@types/node": "22.13.9",
"@typescript-eslint/eslint-plugin": "8.26.0",
"@typescript-eslint/parser": "8.26.0",
"@eslint/js": "^9.37.0",
"@types/node": "24.6.2",
"builtin-modules": "5.0.0",
"esbuild": "0.25.0",
"esbuild": "^0.25.10",
"eslint": "^9.37.0",
"eslint-plugin-obsidianmd": "^0.1.4",
"fast-equals": "5.2.2",
"globals": "16.0.0",
"globals": "16.4.0",
"luxon": "3.7.2",
"obsidian": "latest",
"ts-fsrs": "5.2.1",
"tslib": "2.8.1",
"typescript": "5.8.2",
"typescript-eslint": "8.26.0"
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1"
},
"type": "module",
"pnpm": {

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
import { CardDeclarationAssistant, IDScope } from "declarations/CardDeclaration";
import { Declaration } from "declarations/Declaration";
import { DeclarationParser } from "declarations/DeclarationParser";
import { FullSectionRange, SectionRange } from "FileParser";
import { CardID, FullID, NoteID } from "FullID";
import { FullSectionRange, SectionRange } from "utils/obs/FileParser";
import { CardID, FullID, NoteID } from "data/FullID";
import { App, CachedMetadata, HeadingCache, SectionCache, TFile } from "obsidian";
import { asNoteID, fullIDFromDeclaration } from "TypeAssistant";
import { UnexpectedUndefinedError } from "utils/errors";
/**
* The result of attempting to retrieve the content of a particular {@link FullID}.
@ -160,7 +161,8 @@ export class ContentParser extends DeclarationParser {
await this.getContentFromFile(file, app, parseResult, predicate, options);
// As soon as both sides are found, stop iterating through the rest of the files.
if (Object.hasOwn(parseResult, cardID) && this.isComplete(parseResult[cardID]))
const maybeCard = parseResult[cardID];
if (maybeCard !== undefined && this.isComplete(maybeCard))
break;
}
@ -183,8 +185,7 @@ export class ContentParser extends DeclarationParser {
const complete: Record<CardID, ParsedCardResult> = {};
for (const cardId in maybeIncompleteCards) {
const maybe = maybeIncompleteCards[cardId];
for (const [cardId, maybe] of Object.entries(maybeIncompleteCards)) {
if (this.isComplete(maybe)) {
complete[cardId] = {
complete: maybe,
@ -248,6 +249,9 @@ export class ContentParser extends DeclarationParser {
for (let headingIndex = 0; headingIndex < numberOfHeadings; headingIndex++) {
const currentHeading = headings[headingIndex];
if (currentHeading === undefined)
throw new UnexpectedUndefinedError();
const id = this.findFullIDInText(currentHeading.heading, noteID);
if (!id)
continue;
@ -258,7 +262,7 @@ export class ContentParser extends DeclarationParser {
while (nextHeadingIndex < numberOfHeadings && nextFrontHeadingStartPos === null) {
const nextHeading = headings[nextHeadingIndex];
if (nextHeading.level <= currentHeading.level)
if (nextHeading !== undefined && nextHeading.level <= currentHeading.level)
nextFrontHeadingStartPos = nextHeading;
nextHeadingIndex += 1;
}
@ -365,6 +369,9 @@ export class ContentParser extends DeclarationParser {
result[cardID] = {};
const card = result[cardID];
if (card === undefined)
throw new UnexpectedUndefinedError();
if (idContentInfo.id.isFrontSide) {
card.frontMarkdown = getContent(idContentInfo, contentInfos);
card.frontID = idContentInfo.id;

View file

@ -1,33 +0,0 @@
import { App, KeymapEventListener, Scope } from "obsidian";
export class InternalApi {
/**
* Add {@link listener} to the first currently configured keymap identified by {@link id}.
* @returns `false` if no keymap for {@link id} was found.
*/
public static addEventHandlerToExistingKeyMap(app: App, scope: Scope, id: string, listener: KeymapEventListener) {
// @ts-ignore
const manager = app.hotkeyManager;
const keys: {
// @ts-ignore
modifiers: Modifier[];
key: string;
}[] = manager.customKeys[id] ?? manager.defaultKeys[id];
if (keys.length > 0) {
scope.register(keys[0].modifiers, keys[0].key, listener);
return true;
}
else {
return false;
}
}
public static reloadApp(app: App) {
// @ts-ignore
app.commands.executeCommandById("app:reload");
}
}

View file

@ -1,344 +0,0 @@
import { CardIDDataTuple, DataStore, StatisticsData } from 'DataStore';
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(data: DataStore, config: FsrsSchedulerConfig = Scheduler.DEFAULT_CONFIG) {
this.data = data;
this.configure(config);
}
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() {
return Scheduler.asStatistics(createEmptyCard(new Date()));
}
/**
* @param id ID of the item to rate.
* @param grade The grade to rate.
* @param scheduleDate The date for the scheduling. Omit to schedule at the time of call (recommended).
*/
public rateItem(id: FullID, grade: Grade, scheduleDate?: Date) {
this.data.editCard(id, (editor) => {
const recordLogItem = this.fsrs.next(Scheduler.asCard(editor.data.s), scheduleDate ?? new Date(), grade);
Scheduler.setStatistics(editor.data.s, recordLogItem.card);
//this.data.log.unshift(recordLogItem.log);
return true;
});
}
public previewNextItem(data: StatisticsData, reviewDate?: Date) {
return this.fsrs.repeat(Scheduler.asCard(data), reviewDate ?? new Date());
}
/**
* Get the next review item from {@link cards}.
*
* Not necessarily deterministic.
*
* @param cards
* @param reviewDate
* @returns `null` if there is noting to review at {@link reviewDate}.
*/
public getNextItem(cards: CardIDDataTuple[], reviewDate: Date): {
id: FullID;
statistics: StatisticsData;
} | null {
const items = cards.map<DataItem>(i => ({
id: i.id,
card: Scheduler.asCard(i.data.s)
}));
const nextItem = items
.sort(Scheduler.Comparer.sortDueDateAsc)
.filter(i => this.isCardDue(i.card, reviewDate)).first();
return nextItem ? { id: nextItem.id, statistics: Scheduler.asStatistics(nextItem.card) } : null;
}
//#region
/**
*
* @param lastState
* @param groupedByStateSortedByDueDate Cards with {@link State.Relearning} are expected to be merged with {@link State.Learning}.
* @returns If {@link State.Learning} is returned, cards in {@link State.Relearning} state may also be used.
*/
private getNextStateToUse(date: Date, lastState: State, groupedByStateSortedByDueDate: Record<State, {
id: FullID;
card: Card;
}[]>): State {
const hasNewItems = groupedByStateSortedByDueDate[State.New].length > 0;
const hasLearningItems = groupedByStateSortedByDueDate[State.Learning].length > 0;
const hasRelearningItems = groupedByStateSortedByDueDate[State.Relearning].length > 0;
const hasReviewItems = groupedByStateSortedByDueDate[State.Review].length > 0;
if (hasRelearningItems)
throw new Error("Internal Error");
let nextState = State.New;
switch (lastState) {
case State.New:
if (hasLearningItems)
nextState = State.Learning;
else if (hasReviewItems)
nextState = State.Review;
break;
case State.Learning:
case State.Relearning:
if (hasReviewItems)
nextState = State.Review;
else if (hasNewItems)
nextState = State.New;
else
nextState = State.Learning;
break;
case State.Review:
if (hasLearningItems)
nextState = State.Learning;
else if (hasNewItems)
nextState = State.New;
else
nextState = State.Review;
break;
}
// If next state is learning/relearning and there are cards in those states due, go ahead.
// The first card in the array is expected to be due next.
if (nextState === State.Learning && hasLearningItems && this.isCardDue(groupedByStateSortedByDueDate[State.Learning][0].card, date)) {
return State.Learning;
}
else {
if (!hasNewItems)
return State.Review
else if (!hasReviewItems)
return State.New
else
return Math.random() > 0.5 ? State.Review : State.New
}
}
private groupItemsByState(items: DataItem[], groupRelearningAsLearning: boolean, excludeItem?: DataItem) {
const grouped: Record<State, DataItem[]> = {
[State.New]: [],
[State.Learning]: [],
[State.Relearning]: [],
[State.Review]: [],
};
for (const item of items) {
if (excludeItem && excludeItem.id.isEqual(item.id))
continue;
if (groupRelearningAsLearning && item.card.state === State.Relearning)
grouped[State.Learning].push(item);
else
grouped[item.card.state as State].push(item);
}
return grouped;
}
private asCard(id: FullID) {
return Scheduler.asCard(this.data.getCard(id, true)!.s);
}
//#endregion
private nextState(rating: Rating, card?: Card) {
const nextMemoryState = this.fsrs.next_state(
card ? { stability: card.stability, difficulty: card.difficulty } : null,
card ? card.elapsed_days : 0,
rating,
);
}
private previewNextItemByID(id: FullID, reviewDate?: Date) {
return this.fsrs.repeat(this.asCard(id), reviewDate ?? new Date());
}
//#region
/**
* @param card
* @param date The {@link Date} to compare against. If later than {@link card}, then the latter is due.
* @returns
*/
public isCardDue(card: Card, date: Date) {
return Scheduler.isDateLater(card.due, date);
}
public isStatisticsDue(data: StatisticsData, compareDate: Date = new Date()) {
return Scheduler.isDateLater(TypeConvert.time(data.due), compareDate);
}
/**
*
* @param date
* @param compareDate
* @returns `true` if {@link compareDate} is later than {@link date}.
*/
public static isDateLater(date: Date | string, compareDate: Date = new Date()): boolean {
if (typeof date === 'object' && date instanceof Date) {
return compareDate.getTime() - date.getTime() > 0 ? true : false;
} else {
return this.isDateLater(TypeConvert.time(date), compareDate);
}
}
public retrievability(statistics: StatisticsData, date?: Date | string) {
return this.fsrs.get_retrievability(Scheduler.asCard(statistics), date ?? new Date(), false);
}
//#endregion
private static Comparer = class {
/**
* Sort by last reviewed.
*
* Sort by the date an item was latest reviewed descending,
* i.e., the item with the latest last review date will be sorted first.
*
* Items without a last review date will be sorted last as they have not been reviewed.
*/
public static sortByLastReviewDateDesc(a: DataItem, b: DataItem): number {
if (b.card.last_review && a.card.last_review)
return TypeConvert.time(b.card.last_review).getTime() - TypeConvert.time(a.card.last_review).getTime();
if (b.card.last_review !== undefined && a.card.last_review === undefined)
return 1;
if (b.card.last_review === undefined && a.card.last_review !== undefined)
return -1;
return 0;
}
public static sortDueDateAsc(a: DataItem, b: DataItem): number {
return TypeConvert.time(a.card.due).getTime() - TypeConvert.time(b.card.due).getTime();
}
public static sortByRetrievability(fsrs: FSRS, date: Date, a: DataItem, b: DataItem) {
const ar = fsrs.get_retrievability(a.card, date, false);
const br = fsrs.get_retrievability(b.card, date, false);
if (ar < br) return -1;
if (ar > br) return 1;
return 0;
}
public static newest(a: DataItem, b: DataItem): number {
if (a.card.state == State.New && b.card.state == State.New)
return Scheduler.Comparer.sortDueDateAsc(a, b);
if (a.card.state == State.New && b.card.state != State.New)
return 1;
if (a.card.state != State.New && b.card.state == State.New)
return -1
const aIsLearningOrRelearning = a.card.state == State.Learning || a.card.state == State.Relearning;
const bIsLearningOrRelearning = b.card.state == State.Learning || b.card.state == State.Relearning;
if (aIsLearningOrRelearning && bIsLearningOrRelearning)
return Scheduler.Comparer.sortDueDateAsc(a, b);
if (aIsLearningOrRelearning && !bIsLearningOrRelearning)
return 1;
if (!aIsLearningOrRelearning && bIsLearningOrRelearning)
return -1;
return Scheduler.Comparer.sortDueDateAsc(a, b);
}
}
private static asCard(s: StatisticsData): Card {
return {
due: TypeConvert.time(s.due),
stability: s.s,
difficulty: s.d,
// TODO: Delete in ts-fsrs 6. Value not used.
elapsed_days: 0,
scheduled_days: s.sd,
learning_steps: s.ls,
reps: s.r,
lapses: s.l,
state: s.st as State,
// 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,
};
}
private static asStatistics(card: Card): StatisticsData {
return {
due: card.due.toISOString(),
s: card.stability,
d: card.difficulty,
sd: card.scheduled_days,
ls: card.learning_steps,
r: card.reps,
l: card.lapses,
st: card.state,
lr: toIsoStringOrNull(card.last_review),
} satisfies StatisticsData;
}
/**
* Updates the properties of a {@link StatisticsData} object with the values from a {@link Card} object.
* @param s The {@link StatisticsData} object to update.
* @param card The {@link Card} object to source the new values from.
*/
private static setStatistics(s: StatisticsData, card: Card) {
s.due = card.due.toISOString();
s.s = card.stability;
s.d = card.difficulty;
s.sd = card.scheduled_days;
s.ls = card.learning_steps;
s.r = card.reps;
s.l = card.lapses;
s.st = card.state;
s.lr = toIsoStringOrNull(card.last_review);
}
}

View file

@ -1,7 +1,7 @@
import { PluginSettingTab, Setting, Plugin } from "obsidian";
import { PLUGIN_NAME } from "UIAssistant";
import { Env } from "env";
import { deepEqual } from 'fast-equals';
import { isString } from "TypeAssistant";
import { PLUGIN_NAME } from "ui/constants";
export interface PluginSettings {
uiPrefix: string;
@ -37,6 +37,13 @@ export interface FixedIntervalScheduler {
export type SchedulerSetting = FsrsScheduler | FixedIntervalScheduler;
const SCHEDULER_ID_DEFUALT = "default";
const DEFAULT_SCHEDULER: FsrsScheduler = {
type: "fsrs",
config: {
enableFuzz: true,
reviewSortOrder: "due"
}
} satisfies FsrsScheduler;
export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void;
@ -57,13 +64,7 @@ export class SettingsManager {
removedItemsPurgeThreshold: 24 * 60 * 60,
defaultScheduler: SCHEDULER_ID_DEFUALT,
schedulers: {
[SCHEDULER_ID_DEFUALT]: {
type: "fsrs",
config: {
enableFuzz: true,
reviewSortOrder: "due"
}
}
[SCHEDULER_ID_DEFUALT]: DEFAULT_SCHEDULER
}
};
@ -110,54 +111,8 @@ 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 {
private settingsManager: SettingsManager;
public constructor(plugin: Plugin, settingsManager: SettingsManager) {
super(plugin.app, plugin);
this.settingsManager = settingsManager;
}
private onChangedCallback: SettingsChanged = (_, isExternal) => {
if (isExternal)
this.display();
}
public display(): void {
this.settingsManager.registerOnChangedCallback(this.onChangedCallback);
const { containerEl } = this;
const settings = this.settingsManager.settings;
containerEl.empty();
new Setting(containerEl)
.setName("UI prefix")
.setDesc(`Adds a prefix to UI elements, such as menu items and notices, to help distinguish them from other sources when not obvious. Leave empty to disable.`)
.addText((component) => {
component.setValue(settings.uiPrefix);
component.onChange(async (value) => {
settings.uiPrefix = value;
await this.settingsManager.save();
});
});
new Setting(containerEl)
.setName("Hide card heading in review")
.setDesc(`Hide the headings that start the sections that contains cards sides.`)
.addToggle((component) => {
component.setValue(settings.hideCardSectionMarker);
component.onChange(async (value) => {
settings.hideCardSectionMarker = value;
await this.settingsManager.save();
});
});
}
public hide(): void {
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
const scheduler = this.settings.schedulers[isString(this.settings.defaultScheduler) ? this.settings.defaultScheduler : SCHEDULER_ID_DEFUALT];
Env.assert(scheduler !== undefined, "Corrupt settings.");
return scheduler !== undefined ? scheduler : DEFAULT_SCHEDULER;
}
}

View file

@ -1,6 +1,7 @@
import { DeckableFullID, FullID, NoteID } from "data/FullID";
import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration";
import { DeckableFullID, FullID, NoteID } from "FullID";
import { TFile } from "obsidian";
import { Num, Obj, Str } from "utils/ts";
export function asNoteID(value: TFile | string): NoteID {
if (value instanceof TFile)
@ -23,15 +24,15 @@ export function fullIDFromDeclaration(declaration: CardDeclarable, noteID: NoteI
* @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`.
*/
export function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null; // `null` is an object
return Obj.is(value);
}
export function isString(value: unknown): value is string {
return typeof value === "string";
return Str.is(value);
}
export function isNumber(value: unknown): value is number {
return typeof value === "number";
return Num.is(value);
}
export function isDate(value: unknown): value is Date {
@ -55,16 +56,3 @@ 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
}

98
src/commands/openView.ts Normal file
View file

@ -0,0 +1,98 @@
import { DataStore } from "data/DataStore";
import { DeclarationParser } from "declarations/DeclarationParser";
import t from "Localization";
import { SelectDeckModal } from "modals/SelectDeckModal";
import { App, Command, Keymap, MarkdownView, PaneType, TFile } from "obsidian";
import { UIAssistant } from "ui/UIAssistant";
import { DecksView } from "views/DecksView";
import { DefinedContentView } from "views/DefinedContentView";
import { ReviewView } from "views/review/ReviewView";
export const OpenView = {
collections: async (app: App, paneType?: PaneType | boolean) => {
const leaf = app.workspace.getLeaf(paneType);
await leaf.setViewState({
type: DecksView.TYPE,
active: true,
});
},
definedContent: async (app: App, file: TFile, paneType: PaneType | boolean) => {
await app.workspace.getLeaf(paneType).setViewState({
type: DefinedContentView.TYPE,
state: DefinedContentView.createViewState(file),
active: true,
pinned: undefined,
group: undefined,
});
},
review: async (app: App, dataStore: DataStore, paneType?: PaneType | boolean) => {
const openView = async (state: Record<string, unknown> | undefined, paneType?: PaneType | boolean) => {
const leaf = app.workspace.getLeaf(paneType);
await leaf.setViewState({
type: ReviewView.TYPE,
state: state,
active: true,
pinned: undefined,
group: undefined,
});
};
const allDecks = dataStore.getAllDecks();
if (allDecks.length) {
const modal = new SelectDeckModal(
app,
dataStore,
[...[UIAssistant.allDecksOptionItem()], ...allDecks],
async (deck, evt) => {
await openView(ReviewView.createViewState(deck.id === UIAssistant.DECK_ID_NONE ? null : deck.id), Keymap.isModEvent(evt));
});
modal.setPlaceholder(t.modals.selectDeck.placeholder);
modal.open();
}
else {
await openView(undefined, paneType);
}
}
};
export const OpenViewCommand = {
collections: (app: App): Command => {
return {
id: DecksView.TYPE + '-open',
name: t.commands.openDecks.name,
callback: () => OpenView.collections(app)
};
},
definedContent: (app: App): Command => {
return {
id: 'view-defined-content-in-current-note',
name: t.commands.openDeclarations.name,
checkCallback: (checking: boolean) => {
const markdownView = app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView && markdownView.file && DeclarationParser.containsDeclarations(markdownView.file, app)) {
if (!checking)
OpenView.definedContent(app, markdownView.file, false);
return true;
}
return false;
}
};
},
review: (app: App, dataStore: DataStore): Command => {
return {
id: ReviewView.TYPE + "-open",
name: t.commands.openReview.name,
callback: () => OpenView.review(app, dataStore)
};
},
};

104
src/commands/reviewView.ts Normal file
View file

@ -0,0 +1,104 @@
import t from "Localization";
import { ReviewItemInfoModal } from "modals/ReviewItemInfoModal";
import { App, Command } from "obsidian";
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
import { Ratings, ReviewSortOrder } from "scheduling/types";
import { ReviewView } from "views/review/ReviewView";
export const ReviewViewCommand = {
setSortOrder: (app: App): Command[] => {
const commands: Command[] = [];
for (const sortOrder of ["due", "retrievability"] satisfies ReviewSortOrder[]) {
commands.push({
id: ReviewView.TYPE + "-order-by-" + sortOrder,
name: "Set review sort order to " + sortOrder,
checkCallback: (checking: boolean) => {
const reviewView = app.workspace.getActiveViewOfType(ReviewView);
if (reviewView) {
if (!checking)
reviewView.setSortOrder(sortOrder);
return true;
}
return false;
}
});
}
return commands;
},
rate: (app: App): Command[] => {
const commands: Command[] = [];
for (const rating of Ratings) {
const ratingString = ReviewItemInfo.Convert.ratingAsString(rating);
commands.push({
id: ReviewView.TYPE + "-rate-" + ratingString,
name: "Rate " + ratingString.toLowerCase() + ` (${rating})`,
checkCallback: (checking: boolean) => {
const reviewView = getFacade(app);
if (reviewView !== null && reviewView.rate !== null) {
if (!checking)
reviewView.rate(rating);
return true;
}
return false;
}
});
}
return commands;
},
navigateToSourceFile: (app: App): Command => {
return {
id: ReviewView.TYPE + "-open-source-file",
name: "Open source note",
checkCallback: (checking: boolean) => {
const reviewView = getFacade(app);
if (reviewView !== null && reviewView.openSourceFile !== null) {
if (!checking)
reviewView.openSourceFile();
return true;
}
return false;
}
};
},
toggleInlineInfo: (app: App): Command => {
return {
id: ReviewView.TYPE + "-toggle-inline-info",
name: t.review.actions.toggleInlineInfo,
checkCallback: (checking: boolean) => {
const reviewView = getFacade(app);
if (reviewView !== null && reviewView.toggleShowMetadata !== null) {
if (!checking)
reviewView.toggleShowMetadata();
return true;
}
return false;
}
};
},
showInfoModal: (app: App): Command => {
return {
id: ReviewView.TYPE + "-open-info",
name: "Show info",
checkCallback: (checking: boolean) => {
const reviewView = getFacade(app);
if (reviewView !== null) {
if (!checking)
new ReviewItemInfoModal(app, reviewView.reviewState, reviewView.sortOrder, reviewView.reviewItemInfo()).open();
return true;
}
return false;
}
};
},
};
function getFacade(app: App) {
return app.workspace.getActiveViewOfType(ReviewView)?.getFacade() ?? null;
}

View file

@ -1,8 +1,11 @@
import { CardID, FullID, NoteID, DeckID, DeckableFullID } from "FullID";
import { CardID, DeckID, DeckableFullID, FullID, NoteID } from "data/FullID";
import { UniqueID } from "data/UniqueID";
import { Env } from "env";
import { deepEqual, strictDeepEqual } from 'fast-equals';
import { asNoteID, isDate, isString } from "TypeAssistant";
import { UniqueID } from "UniqueID";
import { Env } from "env";
import { DateTime } from "utils/datetime";
import { UnexpectedUndefinedError } from "utils/errors";
import { Obj, Str } from "utils/ts";
export interface DataStoreRoot {
decks: DecksData;
@ -28,16 +31,20 @@ interface NoteData {
type RemovedData = Record<NoteID, RemovedNoteData>;
/** Serves as a reminder that dates are read and stored as ISO strings. */
type DateString = string;
type IsoDateString = string;
/** Make sure dates are never of `undefined` type. */
type OptionalIsoDateString = IsoDateString | null;
interface RemovedCardData extends CardData {
/** Date marked for removal. */
date: DateString;
date: IsoDateString;
}
interface RemovedNoteData {
cs: Record<CardID, RemovedCardData>;
}
/** Shallow type validation. */
const isRemovedNoteData = (value: unknown): value is RemovedNoteData => Obj.is(value) && "cs" in value;
type LogID = string;
type CardsData = Record<CardID, CardData>;
@ -48,27 +55,74 @@ export interface CardData {
d: DeckID[];
/** The review log id. */
l: LogID[];
/**
* Created date.
* @since 0.6.0 Make sure to call {@link validateCardData} before use.
*/
c: OptionalIsoDateString,
}
/** Shallow type validation. */
const isCardData = (value: unknown): value is CardData => Obj.is(value) && "s" in value && "d" in value;
/** Checks for undefined values, which can happen if values weren't deserialized. */
const validateCardData = (value: CardData) => {
if (!Str.is(value.c))
value.c = null;
};
export interface StatisticsData {
/** Due date. ISO 8601. */
due: string;
due: IsoDateString;
/** stability */
s: number;
/** difficulty */
d: number;
/** scheduled_days */
/**
* `scheduled_days`
*
* The {@link due | due date} minus {@link lr | last review date} in days.
*/
sd: number;
/** learning_steps */
/**
* `learning_steps`
*/
ls: number;
/** reps */
/**
* `reps`
*/
r: number;
/** lapses */
/**
* `lapses`
*
* Incremented by one if, and only if, rated *Again*, which tells the algorithm that the review failed, forcing the card back into the (re)learning phase.
*
* - The `lapses` counter is incremented by exactly one every time a card is rated *Again*. The only exceptions are administrative functions:
* - `rollback()` can decrease the count if an 'Again' rating is undone.
* - `forget()` can reset the count to zero.
*/
l: number;
/** state */
/**
* `state`
*
* - **New**: The card has been created but has not been studied yet.
* - **Learning**: The card is being learned for the first time. It will typically be shown in short intervals.
* - **Review**: The card has been successfully learned and is now in the long-term review cycle to maintain memory retention.
* - **Relearning**: The card was previously in the 'Review' state but was forgotten (rated 'Again'). It must be learned again before returning to the long-term review cycle.
*/
st: number;
/** last_review ISO 8601. */
lr: string | null;
/**
* `last_review`
*
* The date of the last rating, or `null` if never rated.
*
* - ISO 8601 format.
* - Note that if statistics is set to `forget`, state resets to New but `last_review` is not changed.
*/
lr: OptionalIsoDateString;
}
export type CardPredicate = (id: FullID, data: CardData) => boolean;
@ -131,18 +185,17 @@ export class DeckEditor {
this.data.n = name;
}
public setParent(parentID?: DeckID) {
/**
* @param parentID Set to `null` to remove all parents.
*/
public setParent(parentID: DeckID | null) {
console.assert(this.data.p.length <= 1, "Multiple deck parents not implemented.");
this.data.p = parentID ? [parentID] : [];
}
public static getParents(parentID?: DeckID) {
return parentID ? [parentID] : [];
this.data.p = parentID !== null ? [parentID] : [];
}
public static parent(data: DeckData) {
console.assert(data.p.length <= 1, "Multiple deck parents not implemented.");
return data.p.first();
return data.p.first() ?? null;
}
};
@ -182,7 +235,7 @@ export class DataStore {
for (const deckID of card.d) {
const deckData = this.getDeck(deckID);
console.assert(deckData);
if (deckData)
if (deckData !== null)
decks.push(deckData);
}
if (decks.length > 0)
@ -191,16 +244,17 @@ export class DataStore {
return info;
}
//#region Decks
public createDeck(cb: (editor: DeckEditor) => any): DeckIDDataTuple {
/**
* @param cb Return value is ignored.
*/
public createDeck(cb: (editor: DeckEditor) => unknown): DeckIDDataTuple {
const editor = new DeckEditor(UniqueID.generateID(), {
n: "",
p: [],
});
cb(editor)
cb(editor);
this.data.decks[editor.id] = editor.data;
this.setDataDirty();
return { id: editor.id, data: editor.data };
@ -243,7 +297,7 @@ export class DataStore {
predicate: (deck) => DataStore.Predicate.isParentDeck(deck, idToDelete),
}).forEach(childDeck => {
this.editDeck(childDeck.id, editor => {
editor.setParent(undefined);
editor.setParent(null);
return true;
});
});
@ -261,8 +315,8 @@ export class DataStore {
const decks: DeckIDDataTuple[] = [];
for (const id of Object.keys(this.data.decks)) {
const deck = { id: id, data: this.data.decks[id] } satisfies DeckIDDataTuple;
for (const [id, data] of Object.entries(this.data.decks)) {
const deck = { id: id, data: data } satisfies DeckIDDataTuple;
if (!predicate || predicate(deck))
decks.push(deck);
}
@ -272,10 +326,6 @@ export class DataStore {
return decks;
}
//#endregion
//#region
/**
* Deletes {@link CardData} with {@link id} from {@link DataStoreRoot.removed} and returns it.
*
@ -290,8 +340,11 @@ export class DataStore {
if (!removedCard)
return null;
const removedNoteData = this.getRemovedNote(id.noteID, throwIfNotFound)!;
console.assert(removedNoteData);
const removedNoteData = this.getRemovedNote(id.noteID, throwIfNotFound);
Env.assert(removedNoteData);
if (removedNoteData === null)
return null;
delete removedNoteData.cs[id.cardIDOrThrow()];
this.setDataDirty();
@ -309,7 +362,7 @@ export class DataStore {
const time = removedBeforeDate.getTime();
this.getAllRemovedCards(undefined, (_cardID: CardID, data: RemovedCardData) => {
const date = StatisticsHelper.ensureDate(data.date);
console.assert(date, "Expected date parsable string.");
Env.dev?.assert(date, "Expected date parsable string.");
return date && date.getTime() < time ? true : false;
}).forEach(tuple => this.deleteRemovedCard(tuple.id));
}
@ -417,17 +470,13 @@ export class DataStore {
}
}
//#endregion
//#region Remove
public removeNote(noteID: NoteID) {
return this.moveActiveNoteToRemoved(noteID);
}
public async removeAllCards() {
for (const noteID of Object.keys(this.data.active)) {
for (const cardID of Object.keys(this.data.active[noteID].cs))
for (const [noteID, note] of Object.entries(this.data.active)) {
for (const cardID of Object.keys(note.cs))
this.moveActiveCardToRemoved(StatisticsHelper.createFullID(noteID, cardID));
}
}
@ -463,15 +512,17 @@ export class DataStore {
id.throwIfNoCardID();
const note = this.getNote(id.noteID, throwIfNotFound);
let card: CardData | null = null;
if (note === null)
return null;
if (note && Object.prototype.hasOwnProperty.call(note.cs, id.cardID)) {
card = note.cs[id.cardID]
delete note.cs[id.cardID];
if (StatisticsHelper.isNoteEmpty(note))
this.deleteActiveNote(id.noteID);
this.setDataDirty();
}
const card = note.cs[id.cardID];
if (card === undefined)
return null;
delete note.cs[id.cardID];
if (StatisticsHelper.isNoteEmpty(note))
this.deleteActiveNote(id.noteID);
this.setDataDirty();
return card;
}
@ -483,7 +534,7 @@ export class DataStore {
*/
private deleteActiveNote(noteID: NoteID, throwIfNotFound = false) {
const note = this.getNote(noteID, throwIfNotFound);
if (note) {
if (note !== null) {
delete this.data.active[noteID];
this.setDataDirty();
}
@ -497,24 +548,26 @@ export class DataStore {
*/
private deleteRemovedNote(noteID: NoteID, throwIfNotFound = false) {
const note = this.getRemovedNote(noteID, throwIfNotFound);
if (note) {
if (note !== null) {
delete this.data.removed[noteID];
this.setDataDirty();
}
return note;
}
//#endregion
//#region Get
public getCard(id: FullID, throwIfNotFound = false) {
public getCard(id: FullID, throwIfNotFound = false): CardData | null {
id.throwIfNoNoteID();
id.throwIfNoCardID();
const data = this.getNote(id.noteID, throwIfNotFound)?.cs[id.cardID] ?? null;
if (data === null && throwIfNotFound)
throw new Error(`Card ${id} was not found.`);
if (data !== null) {
Env.assert(isCardData(data), "Invalid JSON for id:", id.toString());
validateCardData(data);
}
return data;
}
@ -558,7 +611,9 @@ export class DataStore {
* @returns
*/
public getAllCardsForDeck(deckID?: DeckID): CardIDDataTuple[] {
if (!deckID)
Env.log.d("DataStore:getAllCardsForDeck:deckID", deckID);
Env.dev?.assert(deckID === undefined || isString(deckID) && deckID !== Env.str.EMPTY, deckID);
if (deckID === undefined)
return this.getAllCards();
const data = this.getDeck(deckID, true);
@ -574,7 +629,7 @@ export class DataStore {
}
private descendantDecks(parentID?: DeckID): DeckIDDataTuple[] {
if (!parentID)
if (parentID === undefined)
return [];
const childDecks = this.getAllDecks({
@ -597,7 +652,7 @@ export class DataStore {
noteFilter?: (noteID: NoteID, data: NoteData) => boolean,
cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
let cards: CardIDDataTuple[] = [];
const cards: CardIDDataTuple[] = [];
for (const [noteID, note] of Object.entries(this.data.active)) {
if (noteFilter && noteFilter(noteID, note) === false)
@ -622,34 +677,41 @@ export class DataStore {
noteFilter?: (noteID: NoteID, data: RemovedNoteData) => boolean,
cardFilter?: (cardID: CardID, data: RemovedCardData) => boolean) {
let cards: RemovedCardIDDataTuple[] = [];
const cards: RemovedCardIDDataTuple[] = [];
for (const [noteID, removedData] of Object.entries(this.data.removed)) {
if (!removedData.cs) // Object.entries will throw
continue;
// The actual json values can contain anything (for example `"removed": { "ad": "s" }`).
// If, e.g., the value sent to `Object.entries` is `undefined`, it will throw.
if (Str.isNonEmpty(noteID) && isRemovedNoteData(removedData)) {
try {
// Run note filter
if (noteFilter && noteFilter(noteID, removedData) === false)
continue;
if (noteFilter && noteFilter(noteID, removedData) === false)
continue;
for (const [cardID, card] of Object.entries(removedData.cs)) {
// Run card filter
if (cardFilter && cardFilter(cardID, card) === false)
continue;
for (const [cardID, card] of Object.entries(removedData.cs)) {
if (cardFilter && cardFilter(cardID, card) === false)
continue;
cards.push({
id: FullID.create(noteID, cardID, true), // back sides are not stored
data: card
});
cards.push({
id: FullID.create(noteID, cardID, true), // back sides are not stored
data: card
});
}
}
catch (e) {
Env.log.e(e);
}
}
else {
Env.log.w("Invalid JSON value: noteID:", noteID, ", removedData:", removedData);
}
}
return cards;
}
//#endregion
//#region Sync
/**
*
* @param oldID
@ -681,7 +743,7 @@ 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}`)));
Env.dev?.run(() => 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 => {
@ -722,21 +784,26 @@ export class DataStore {
// Neither new item nor removed
else if (noteData) {
const currentDeckIDs = noteData.cs[latestID.cardID].d;
let newDeckIDs: DeckID[] | undefined;
const currCollectionIDs = noteData.cs[latestID.cardID]?.d;
if (currCollectionIDs === undefined)
throw new UnexpectedUndefinedError();
let newCollectionIDs: DeckID[] | undefined;
if (latestID instanceof DeckableFullID) {
// Only update decks if changed
if (!latestID.isDecksEqual(currentDeckIDs))
newDeckIDs = latestID.deckIDs.filter(deckID => this.getDeck(deckID)); // Filter non-existing IDs
if (!latestID.isDecksEqual(currCollectionIDs))
newCollectionIDs = latestID.deckIDs.filter(deckID => this.getDeck(deckID)); // Filter non-existing IDs
}
else {
newDeckIDs = [];
// The data does not support collections.
if (currCollectionIDs.length > 0) // Just in case, if there are associated collections, make sure they are removed.
newCollectionIDs = [];
}
if (newDeckIDs) {
if (newCollectionIDs !== undefined) {
this.editCard(latestID, (editor) => {
editor.setDecks(newDeckIDs);
editor.setDecks(newCollectionIDs);
modifiedIDs.push(latestID);
return true;
});
@ -749,10 +816,6 @@ export class DataStore {
return { addedIDs, removedIDs, modifiedIDs };
}
//#endregion
//#region Persisting data
public async save() {
Env.log.d(`DataStore:save: dirty: ${this._isDataDirty}`);
if (this._isDataDirty) {
@ -804,7 +867,7 @@ export class DataStore {
});
}
else {
Env.dev(() => {
Env.dev?.run(() => {
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");
@ -839,10 +902,6 @@ export class DataStore {
private registeredChangedCallbacks: DataChanged[] = [];
//#endregion
//#region
public readonly filter = {
cardsWithoutDeck: (card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(undefined)(card.id, card.data),
cardsInDeck: (deckId: DeckID, card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(deckId)(card.id, card.data),
@ -882,8 +941,6 @@ export class DataStore {
return a.data.n.localeCompare(b.data.n)
}
}
//#endregion
}
/** See {@link DataStore.onDataChangedExternally} */
@ -897,18 +954,18 @@ export type DataChangedInfo = {
class StatisticsHelper {
public static ensureDate(value: DateString | Date) {
public static ensureDate(value: IsoDateString | Date) {
if (isString(value))
value = new Date(value);
return isDate(value) ? value : null;
}
public static ensureDateString(value: DateString | Date | undefined) {
public static ensureDateString(value: IsoDateString | Date | undefined) {
if (value === undefined)
value = new Date();
if (isDate(value))
value = value.toISOString();
return value as DateString;
return value as IsoDateString;
}
public static isNoteEmpty(note: NoteData) {
@ -923,11 +980,13 @@ class StatisticsHelper {
return FullID.create(noteID, cardID, true); // back sides are not stored
}
/** Creates a new {@link CardData} with created date set to current time. */
public static createCardData(decks: DeckID[], statistics: StatisticsData) {
return {
l: [],
d: decks,
s: statistics,
c: DateTime.toIso(new Date()),
} satisfies CardData;
}
@ -947,7 +1006,8 @@ class StatisticsHelper {
public static removedCardToCard(removedCard: RemovedCardData): CardData {
const {
date,
...cardData } = removedCard;
...cardData
} = removedCard;
return cardData;
}

View file

@ -11,7 +11,7 @@ export class FullID implements FullID {
if (cardSide) {
if (!FullID.isSideValid(cardSide))
throw new Error(`Invalid value for "side": ${cardSide}. Expected: ${FullID.sideValues.join(", ")}.`);
this._cardSide = cardSide?.trim().toLowerCase();
this._cardSide = cardSide.trim().toLowerCase();
}
}
@ -202,10 +202,12 @@ export class DeckableFullID extends FullID {
this.deckIDs = deckIDs;
}
/** Determines if the provided deck IDs are equivalent to this card's decks, ignoring order. */
public isDecksEqual(deckIDs: DeckID[]) {
return (
this.deckIDs.length === deckIDs.length &&
this.deckIDs.every((deck, index) => deck === deckIDs[index])
);
if (this.deckIDs.length !== deckIDs.length)
return false;
const otherDecks = new Set(deckIDs);
return this.deckIDs.every(deckID => otherDecks.has(deckID));
}
}

View file

@ -1,11 +1,12 @@
import { DataStore, StatisticsData } from "DataStore";
import { DataStore, StatisticsData } from "data/DataStore";
import { FullID, NoteID } from "data/FullID";
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";
/** Keeps the plugin's internal data synchronized with card declarations in the vault by listening for and processing file system events. */
export class SyncManager {
public readonly app: App;

View file

@ -1,6 +1,6 @@
import { DeckableDeclarable, Declarable, Declaration, DeclarationRange, YamlParseErrorCallback } from "declarations/Declaration";
import { isString } from "TypeAssistant";
import { UniqueID } from "UniqueID";
import { UniqueID } from "data/UniqueID";
export const enum IDScope {
UNIQUE,

View file

@ -1,6 +1,6 @@
import { CardDeclaration, IDScope } from "declarations/CardDeclaration";
import { CommandableDeclarable } from "declarations/CommandDeclaration";
import { FileParser, SectionRange } from "FileParser";
import { FileParser, SectionRange } from "utils/obs/FileParser";
import { CacheItem } from "obsidian";
export interface CommandDeclarationParsable {
@ -51,7 +51,7 @@ export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
*/
protected lastDeclaration() {
const last = this.generatedDeclarations.last();
if (!last)
if (last === undefined)
throw new Error(`Expected at least one generated declaration.`);
return last;
}

View file

@ -1,7 +1,7 @@
import { FileParser } from "FileParser";
import { FileParser } from "utils/obs/FileParser";
import { parseYaml, stringifyYaml } from "obsidian";
import { isObject, isString } from "TypeAssistant";
import { UniqueID } from "UniqueID";
import { UniqueID } from "data/UniqueID";
export interface Declarable {
[key: string]: unknown;

View file

@ -1,4 +1,4 @@
import { DataStore } from "DataStore";
import { DataStore } from "data/DataStore";
import { CardDeclarationAssistant } from "declarations/CardDeclaration";
import { DeckableDeclarable, Declaration } from "declarations/Declaration";
import { DeclarationRenderChild } from "renderings/declarations/DeclarationRenderChild";

View file

@ -1,10 +1,11 @@
import { FullID, IDFilter, NoteID } from "data/FullID";
import { CardDeclarable, CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
import { CommandableDeclarable, CommandDeclarationAssistant } from "declarations/CommandDeclaration";
import { Declaration, DeclarationRange } from "declarations/Declaration";
import { FileParser, SectionRange } from "FileParser";
import { FullID, IDFilter, NoteID } from "FullID";
import { App, CachedMetadata, CacheItem, FrontMatterCache, HeadingCache, SectionCache, TFile } from "obsidian";
import { asNoteID, fullIDFromDeclaration } from "TypeAssistant";
import { UnexpectedUndefinedError } from "utils/errors";
import { FileParser, SectionRange } from "utils/obs/FileParser";
/**
* Contains auxiliary information collected during the parsing process.
@ -85,7 +86,7 @@ export class DeclarationParser extends FileParser {
// Check there headings that contain an ID
if (cache.headings) {
for (const currentHeading of cache.headings)
if (this.findFullIDInText(currentHeading.heading, noteID))
if (this.findFullIDInText(currentHeading.heading, noteID) !== null)
return true;
}
@ -94,7 +95,7 @@ export class DeclarationParser extends FileParser {
if (this.isCodeSection(section)) {
if (fileContent) {
const info = this.parseCodeBlock(this.extractContentFromSection(section, fileContent));
if (info && Declaration.supportedCodeBlockLanguages.includes(info.language))
if (info !== null && Declaration.supportedCodeBlockLanguages.includes(info.language))
return true;
}
else {
@ -171,7 +172,7 @@ export class DeclarationParser extends FileParser {
// Check there headings that contain an ID
for (const currentHeading of cache.headings ?? []) {
const id = this.findFullIDInText(currentHeading.heading, noteID);
if (id && !checkExistance(id)) {
if (id !== null && !checkExistance(id)) {
if (filter === undefined || (filter && filter(id)))
ids.push(id);
}
@ -188,7 +189,7 @@ export class DeclarationParser extends FileParser {
// Explicit declarations
const explicitDecl = this.getDeclarationFromSection(section, noteID, fileContent, parseInfo);
if (explicitDecl)
if (explicitDecl !== null)
createAndAddIDFromDeclaration(explicitDecl);
// Auto generated declarations
@ -214,14 +215,17 @@ export class DeclarationParser extends FileParser {
const match = this.FULL_ID_REGEX.exec(text);
if (match) {
const kind = match[1].toLowerCase();
const isFront = kind[0] === 'f';
const isBack = kind[0] === 'b';
if (match !== null) {
const kind = match[1]?.toLowerCase();
const cardID = match[2];
if (isFront || isBack)
return FullID.create(noteID, cardID, isFront);
if (kind !== undefined && cardID !== undefined) {
const isFront = kind[0] === 'f';
const isBack = kind[0] === 'b';
if (isFront || isBack)
return FullID.create(noteID, cardID, isFront);
}
}
return null;
@ -241,7 +245,7 @@ export class DeclarationParser extends FileParser {
location: location,
});
});
if (declaration)
if (declaration !== null)
return declaration;
}
return null;
@ -313,7 +317,7 @@ export class DeclarationParser extends FileParser {
}
);
if (parser) {
if (parser !== null) {
this.headingRangeForSection(section, cache, (commandDeclarationSection, inBetweenDelimiter, _sectionNumber, index, delimiters) => {
parser.parse(commandDeclarationSection.level, inBetweenDelimiter, index, delimiters);
});
@ -384,24 +388,28 @@ export class DeclarationParser extends FileParser {
// Start at bottom. The first delimiter that's not after the section is the start delimiter the section belongs to.
for (let delimiterCounter = numberOfDelimiters - 1; delimiterCounter >= 0; delimiterCounter--) {
const delimiter = orderedDelimiters[delimiterCounter];
if (delimiter === undefined)
throw new UnexpectedUndefinedError();
// Delimiter is after section
if (orderedDelimiters[delimiterCounter].position.start.offset > section.position.end.offset)
if (delimiter.position.start.offset > section.position.end.offset)
continue;
const startDelimiter = orderedDelimiters[delimiterCounter];
range.start = startDelimiter;
range.start = delimiter;
// The start delimiter has been found. Now start walking toward the bottom again and let the predicates decide when the end delimiter is found.
for (let nextHeadingIndex = delimiterCounter + 1; nextHeadingIndex < numberOfDelimiters; nextHeadingIndex++) {
const maybeEndDelimiter = orderedDelimiters[nextHeadingIndex];
if (endPredicate(startDelimiter, maybeEndDelimiter)) {
if (maybeEndDelimiter === undefined)
throw new UnexpectedUndefinedError();
if (endPredicate(delimiter, maybeEndDelimiter)) {
range.end = maybeEndDelimiter;
break;
}
else {
inBetweenCallback?.(
startDelimiter,
delimiter,
maybeEndDelimiter,
nextHeadingIndex - (delimiterCounter + 1), // Zero-based index of the in-between delimiter.
nextHeadingIndex,

View file

@ -3,6 +3,7 @@ import { CommandableDeclarable } from "declarations/CommandDeclaration";
import { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable } from "declarations/commands/HeadingsCommandable";
import { CacheItem, HeadingCache } from "obsidian";
import { UnexpectedUndefinedError } from "utils/errors";
export interface HeadingAndDelimiterDeclarable extends HeadingsCommandableDeclarable {
delimiter: "horizontal rule"
@ -87,6 +88,8 @@ export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingA
let nextDelimiter: CacheItem | null = null;
for (let nextIndex = index + 1; nextIndex < delimiters.length; nextIndex++) {
const maybeNextDelimiter = delimiters[nextIndex];
if (maybeNextDelimiter === undefined)
throw new UnexpectedUndefinedError();
// Front side should end as soon as the first delimiter (as specified by the declaration) is found.
if (!this.lastFrontHeadingLevel && HeadingAndDelimiterParser.isSectionType(maybeNextDelimiter, HeadingAndDelimiterParser.SECTION_TYPE_THEMATICBREAK))
@ -119,7 +122,8 @@ export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingA
private tryParseUniqueID(text: string) {
const match = this.FULL_ID_REGEX.exec(text);
return match ? match[1].toLowerCase() : null;
const result = match?.[1];
return result !== undefined ? result.toLowerCase() : null;
}
protected readonly FULL_ID_REGEX = /@([^\s]+)/i;

View file

@ -2,6 +2,7 @@ import { CommandableDeclarable } from "declarations/CommandDeclaration";
import { CommandDeclarationParser } from "declarations/CommandDeclarationParser";
import { CacheItem, HeadingCache } from "obsidian";
import { isNumber } from "TypeAssistant";
import { UnexpectedUndefinedError } from "utils/errors";
/**
* @abstract
@ -62,9 +63,10 @@ export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDec
* @returns `null` if a next heading on the same level or lower was not found.
*/
protected static findNextHeading(headingLevel: number, index: number, delimiters: CacheItem[]) {
let nextDelimiter: CacheItem | null = null;
for (let nextIndex = index + 1; nextIndex < delimiters.length; nextIndex++) {
nextDelimiter = delimiters[nextIndex];
const nextDelimiter = delimiters[nextIndex];
if (nextDelimiter === undefined)
throw new UnexpectedUndefinedError();
if (HeadingsDeclarationParser.isHeadingCache(nextDelimiter) && headingLevel >= nextDelimiter.level)
return nextDelimiter;
}

View file

@ -2,19 +2,27 @@ import { Platform } from "obsidian";
const isProduction = process.env.NODE_ENV === "production";
const isDev = !isProduction;
const noop = () => {};
const noopLogger = {
debug: () => { },
log: () => { },
info: () => { },
warn: () => { },
debug: noop,
log: noop,
info: noop,
warn: noop,
};
const devLogger = isDev ? console : noopLogger;
const DevContext = {
assert: console.assert,
run: (action: () => void) => action(),
} as const;
export const Env = {
isDev: !isProduction,
dev: isProduction ? () => { } : (action: () => void) => action(),
/** Debug/Dev context */
dev: isDev ? DevContext : undefined,
isDev: isDev,
noop: noop,
/** Always logs */
error: console.error,
@ -40,13 +48,15 @@ export const Env = {
i: devLogger.info,
/** To indicate a potential issue, a suboptimal practice, a deprecated feature being used, or a situation that might lead to an error later but isn't critical right now. It's a "heads up" or a "soft error." */
w: devLogger.warn,
w: console.warn,
e: console.error,
/** Debug log for content processors. See {@link ContentRendererProcessor}. */
proc: false ? devLogger.debug : noopLogger.debug,
proc: devLogger.info, // devLogger.info : noopLogger.debug,
/** Debug log for views. */
view: false ? devLogger.debug : noopLogger.debug,
view: devLogger.info, // devLogger.info : noopLogger.debug,
},
@ -66,4 +76,14 @@ export const Env = {
return true;
return false;
},
/** If running in mobile app that has very limited screen space. */
isPhone: Platform.isPhone,
/** If running in mobile app that has sufficiently large screen space. */
isTablet: Platform.isTablet,
str: {
EMPTY: "",
} as const
} as const;

View file

@ -1,4 +1,3 @@
import { FullID } from "FullID";
import { TFile } from "obsidian";
export const en = {
@ -21,7 +20,14 @@ export const en = {
}
},
settings: {
// Add settings-related strings here
uiPrefix: {
name: "UI prefix",
description: "Adds a prefix to UI elements, such as menu items and notices, to help distinguish them from other sources when not obvious. Leave empty to disable."
},
hideCardHeadingInReview: {
name: "Hide card heading in review",
description: "Hide the headings that start the sections that contains cards sides."
}
},
modals: {
selectDeck: {
@ -29,6 +35,14 @@ export const en = {
},
confirmation: {
title: "External change detected",
pluginName: "Plugin: Come Through",
description: "Detected changes to data (such as cards, decks, rating statistics, etc) from an external source. If this change was expected, e.g., you created a card on another device that syncs with this one, click Accept changes.",
acceptChangesTitle: "Accept changes",
acceptChangesDescription: "Allow this devices cards, decks, rating statistics, etc, to be replaced with those from the other device.",
rejectChangesTitle: "Reject changes",
rejectChangesDescription: "Keep this devices cards, decks, rating statistics, etc. Having selected this option you should allow the other device(s) to overwrite its data.",
acceptChangesButton: "Accept changes by other device",
rejectChangesButton: "Reject changes by other device"
},
},
views: {
@ -36,5 +50,10 @@ export const en = {
title: (file?: TFile) => file ? `Defined content in “${file.basename}` : "Defined content",
fileNotSet: "The note to show content definitions from is unknown."
}
},
review: {
actions: {
toggleInlineInfo: "Toggle inline info",
}
}
};

View file

@ -1,19 +1,23 @@
import { DataStore, DataStoreRoot } from "DataStore";
import { OpenView, OpenViewCommand } from "commands/openView";
import { ReviewViewCommand } from "commands/reviewView";
import { DataStore, DataStoreRoot } from "data/DataStore";
import { SyncManager } from "data/SyncManager";
import { UniqueID } from "data/UniqueID";
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 { 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 { Editor, Keymap, MarkdownPostProcessorContext, MarkdownView, Plugin, TFile } from "obsidian";
import { Scheduler } from "scheduling/Scheduler";
import { FsrsSchedulerConfig } from "scheduling/types";
import { PluginSettings, SettingsChangedInfo, SettingsManager } from "Settings";
import { Icon } from "ui/constants";
import { SettingTab } from "ui/SettingTab";
import { UIAssistant } from "ui/UIAssistant";
import { DecksView } from "views/DecksView";
import { DefinedContentView } from "views/DefinedContentView";
import { ReviewView } from "views/ReviewView";
import { ReviewView } from "views/review/ReviewView";
interface PluginData {
settings: PluginSettings;
@ -53,8 +57,8 @@ export default class ComeThroughPlugin extends Plugin {
this.ui = new UIAssistant(this.settingsManager);
this.addSettingTab(new SettingTab(this, this.settingsManager));
this.addRibbonIcon(PLUGIN_ICON, this.ui.contextulize("Review"), (evt: MouseEvent) => {
this.openReviewView(Keymap.isModEvent(evt));
this.addRibbonIcon(Icon.PLUGIN, this.ui.contextulize("Review"), (evt: MouseEvent) => {
OpenView.review(this.app, this.dataStore, Keymap.isModEvent(evt));
});
this.app.workspace.onLayoutReady(() => this.registerEvents());
@ -64,12 +68,6 @@ export default class ComeThroughPlugin extends Plugin {
if (!this.settingsManager.settings.hideDeclarationInReadingView || UIAssistant.isInInLivePreview(this.app))
DeclarationManager.processCodeBlock(this.app, source, el, ctx, this.dataStore);
}, -100); // Process this code block last, allowing other plugins to alter user input first.
// Register yaml highlighting. Seen while editing (comments, explicit strings, ...). TODO: This is CodeMirror 5 API.
window.CodeMirror.defineMode(language, (config) => window.CodeMirror.getMode(config, "text/x-yaml"));
this.register(() => {
window.CodeMirror.defineMode(language, (config) => window.CodeMirror.getMode(config, "null"));
});
}
// Views
@ -91,31 +89,17 @@ export default class ComeThroughPlugin extends Plugin {
// Commands
this.addCommand({
id: 'open-review',
name: t.commands.openReview.name,
callback: () => this.openReviewView(true)
});
this.addCommand(OpenViewCommand.review(this.app, this.dataStore));
this.addCommand(OpenViewCommand.collections(this.app));
this.addCommand(OpenViewCommand.definedContent(this.app));
this.addCommand({
id: 'open-decks',
name: t.commands.openDecks.name,
callback: () => this.openDecksView(true)
});
this.addCommand({
id: 'view-defined-content-in-current-note',
name: t.commands.openDeclarations.name,
checkCallback: (checking: boolean) => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView && markdownView.file && DeclarationParser.containsDeclarations(markdownView.file, this.app)) {
if (!checking)
this.viewDefinedContent(markdownView.file, false);
return true;
}
return false;
}
});
for (const command of ReviewViewCommand.setSortOrder(this.app))
this.addCommand(command);
for (const command of ReviewViewCommand.rate(this.app))
this.addCommand(command);
this.addCommand(ReviewViewCommand.showInfoModal(this.app));
this.addCommand(ReviewViewCommand.toggleInlineInfo(this.app));
this.addCommand(ReviewViewCommand.navigateToSourceFile(this.app));
this.addCommand({
id: 'generate-id-cursor',
@ -211,63 +195,13 @@ export default class ComeThroughPlugin extends Plugin {
if (isFileIncluded && (/*source === "file-explorer-context-menu" ||*/ source === "more-options" || source === "tab-header")) {
this.ui.addMenuItem(menu, t.actions.viewDeclarationsInFile, {
onClick: async (evt) => this.viewDefinedContent(file, Keymap.isModEvent(evt)),
onClick: async (evt) => OpenView.definedContent(this.app, file, Keymap.isModEvent(evt)),
section: "open",
});
}
}));
}
private async openDecksView(paneType: PaneType | boolean) {
const leaf = this.app.workspace.getLeaf(paneType);
await leaf.setViewState({
type: DecksView.TYPE,
active: true,
});
}
private async openReviewView(paneType: PaneType | boolean) {
const openView = async (paneType: PaneType | boolean, state: Record<string, unknown> | undefined) => {
const leaf = this.app.workspace.getLeaf(paneType);
await leaf.setViewState({
type: ReviewView.TYPE,
state: state,
active: true,
pinned: undefined,
group: undefined,
});
};
const allDecks = this.dataStore.getAllDecks();
if (allDecks.length) {
const modal = new SelectDeckModal(
this.app,
this.dataStore,
[...[UIAssistant.allDecksOptionItem()], ...allDecks],
async (deck, evt) => {
await openView(Keymap.isModEvent(evt), ReviewView.createViewState(deck.id));
});
modal.setPlaceholder(t.modals.selectDeck.placeholder);
modal.open();
}
else {
await openView(paneType, undefined);
}
}
private async viewDefinedContent(file: TFile, paneType: PaneType | boolean) {
await this.app.workspace.getLeaf(paneType).setViewState({
type: DefinedContentView.TYPE,
state: DefinedContentView.createViewState(file),
active: true,
pinned: undefined,
group: undefined,
});
}
private static async loadPluginData(plugin: Plugin): Promise<PluginData> {
const data = await plugin.loadData(); // Returns `null` if file doesn't exist.
@ -305,7 +239,7 @@ export default class ComeThroughPlugin extends Plugin {
private onSettingsSaved(changedInfo?: SettingsChangedInfo) {
switch (changedInfo) {
case "schedulerConfig":
this.scheduler.configure(this.createSchedulerConfig());
this.scheduler.reconfigure(this.createSchedulerConfig());
break;
}
}

66
src/modals/BaseModal.ts Normal file
View file

@ -0,0 +1,66 @@
import { Env } from "env";
import { App, Modal, Setting } from "obsidian";
import { CssClass } from "utils/obs/constants";
/**
* - Adds a plugin specific class to all modals for styling purposes.
* - Adds mobile hacks.
*/
export class BaseModal extends Modal {
constructor(app: App, options?: {
fullscreenOnLimitedScreenSpace: boolean,
}) {
super(app);
// DOM when opening the "Show debug info" modal:
//
// <div class="modal-container mod-dim"> <-- this.containerEl
// <div class="modal-bg" style="opacity: 0.85;"></div>
// <div class="modal mod-lg" style=""> <-- this.modalEl
// <div class="modal-close-button"></div>
// <div class="modal-header">
// <div class="modal-title"></div>
// </div>
// <div class="modal-content"></div> <--- this.contentEl
// <div class="modal-button-container"></div>
// </div>
// </div>
// "Show debug info" modal has this class which makes the height 100% on mobile, i.e. fullscreen.
// If modal contains a lot of content and therefore expands in height, it will never fill the entire screen. So in those cases, it's better to force fullscreen.
if (options?.fullscreenOnLimitedScreenSpace && Env.isPhone)
this.modalEl.addClass(CssClass.Modal.LG);
this.contentEl.addClass("come-through-modal-content");
}
protected createSetting(name: string, o?: { styleControlElAsDesc?: boolean, isHeading?: boolean }) {
const s = new Setting(this.contentEl).setName(name)
if (o?.styleControlElAsDesc)
s.controlEl.addClass(CssClass.Setting.Item.DESC);
if (o?.isHeading)
s.setHeading();
return s;
}
/** Using this you get better bottom margins on mobile than adding buttons on a `Setting`. */
protected getButtonContainer() {
// Try to find existing button container, or create one if it doesn't exist
let buttonContainer = this.modalEl.querySelector<HTMLElement>("." + CssClass.Modal.BUTTON_CONTAINER);
if (buttonContainer === null)
buttonContainer = this.contentEl.createDiv(CssClass.Modal.BUTTON_CONTAINER);
return buttonContainer;
}
protected addDoneButton(onMobileOnly: boolean) {
if (onMobileOnly && !Env.isMobile)
return;
this.getButtonContainer().createEl("button", {
text: "Done",
cls: CssClass.Modal.CANCEL,
}, (button => {
button.addEventListener("click", () => this.close());
}))
}
}

View file

@ -1,34 +1,33 @@
import t from "Localization";
import { App, Modal, Setting } from "obsidian";
import { Platform } from 'obsidian';
import { BaseModal } from "modals/BaseModal";
import { App, Platform, Setting } from "obsidian";
/** @todo Make general */
export class ConfirmationModal extends Modal {
export class ConfirmationModal extends BaseModal {
public onButton1Click?: () => void;
public onButton2Click?: () => void;
public onClosed?: () => void;
private canClose = false;
constructor(app: App) {
super(app);
super(app, { fullscreenOnLimitedScreenSpace: true });
this.setTitle(t.modals.confirmation.title);
new Setting(this.contentEl).setDesc(createFragment((f) => {
f.createEl("i", { text: "Plugin: Come Through" });
f.createEl("p").appendText("Detected changes to data (such as cards, decks, rating statistics, etc) from an external source. If this change was expected, e.g., you created a card on another device that syncs with this one, click Accept changes.");
f.createEl("i", { text: t.modals.confirmation.pluginName });
f.createEl("p").appendText(t.modals.confirmation.description);
f.createEl("p").createEl("b", { text: "Accept changes" });
f.createEl("p").appendText("Allow this devices cards, decks, rating statistics, etc, to be replaced with those from the other device.");
f.createEl("p").createEl("b", { text: t.modals.confirmation.acceptChangesTitle });
f.createEl("p").appendText(t.modals.confirmation.acceptChangesDescription);
f.createEl("p").createEl("b", { text: "Reject changes" });
f.createEl("p").appendText("Keep this devices cards, decks, rating statistics, etc. Having selected this option you should allow the other device(s) to overwrite its data.");
f.createEl("p").createEl("b", { text: t.modals.confirmation.rejectChangesTitle });
f.createEl("p").appendText(t.modals.confirmation.rejectChangesDescription);
}));
new Setting(this.contentEl)
.addButton((button) => {
button.buttonEl.tabIndex = -1;
button.setButtonText(Platform.isMobile ? "Accept changes" : "Accept changes by other device");
button.setButtonText(Platform.isMobile ? t.modals.confirmation.acceptChangesTitle : t.modals.confirmation.acceptChangesButton);
button.onClick(() => {
button.setDisabled(true);
this.onButton1Click?.();
@ -37,7 +36,7 @@ export class ConfirmationModal extends Modal {
})
.addButton((button) => {
button.buttonEl.tabIndex = -1;
button.setButtonText(Platform.isMobile ? "Reject changes" : "Reject changes by other device");
button.setButtonText(Platform.isMobile ? t.modals.confirmation.rejectChangesTitle : t.modals.confirmation.rejectChangesButton);
button.onClick(() => {
button.setDisabled(true);
this.onButton2Click?.();

View file

@ -1,13 +1,14 @@
import { App, ButtonComponent, Modal, Setting } from "obsidian";
import { DeckEditor, DeckIDDataTuple, DataStore } from "DataStore";
import { UIAssistant } from "UIAssistant";
import { DeckID } from "FullID";
import { DataStore, DeckEditor, DeckIDDataTuple } from "data/DataStore";
import { DeckID } from "data/FullID";
import { BaseModal } from "modals/BaseModal";
import { App, ButtonComponent, Setting } from "obsidian";
import { UIAssistant } from "ui/UIAssistant";
export class DeckModal extends Modal {
export class DeckModal extends BaseModal {
private nameOfDeck: string = "";
private parentDeckID?: DeckID;
private parentDeckID: DeckID | null;
private createButton: ButtonComponent;
private result?: DeckIDDataTuple;
@ -22,7 +23,7 @@ export class DeckModal extends Modal {
private readonly data: DataStore,
private readonly onSubmit: (deck: DeckIDDataTuple) => void,
private readonly idToEdit?: DeckID) {
super(app);
super(app, { fullscreenOnLimitedScreenSpace: true });
if (idToEdit) {
const deckToEdit = data.getDeck(idToEdit, true)!;
@ -49,13 +50,13 @@ export class DeckModal extends Modal {
.setDesc(idToEdit ? "" : "To make this deck a subdeck, choose a parent deck.")
.addDropdown((component) => {
component.addOption(UIAssistant.DECK_ID_UNDEFINED, "None");
component.addOption(UIAssistant.DECK_ID_NONE, "None");
for (const deck of this.data.getAllDecks().filter(d => d.id !== this.idToEdit))
component.addOption(deck.id, deck.data.n);
component.setValue(this.parentDeckID ?? UIAssistant.DECK_ID_UNDEFINED);
component.setValue(this.parentDeckID ?? UIAssistant.DECK_ID_NONE);
component.onChange((value) => {
this.parentDeckID = value === UIAssistant.DECK_ID_UNDEFINED ? undefined : value;
this.parentDeckID = value === UIAssistant.DECK_ID_NONE ? null : value;
});
});

View file

@ -0,0 +1,180 @@
import { BaseModal } from "modals/BaseModal";
import { App } from "obsidian";
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
import { ReviewSortOrder } from "scheduling/types";
import { DateTime } from "utils/datetime";
import { TableSectionCreator } from "utils/dom/table";
import { KeyValue, Str } from "utils/ts";
import { ReviewState } from "views/review/types";
export class ReviewItemInfoModal extends BaseModal {
private reviewState: ReviewState;
private info: ReviewItemInfo;
private now: Date;
constructor(app: App, reviewState: ReviewState, sortOrder: ReviewSortOrder, info: ReviewItemInfo) {
super(app, { fullscreenOnLimitedScreenSpace: true });
this.reviewState = reviewState;
this.info = info;
this.now = new Date();
const stats = reviewState.reviewedItem.statistics;
this.setTitle("Info");
//this.createSetting("Queue", { isHeading: true })
this.createSetting("Total units")
.setDesc("The total number of review units in the current queue.")
.controlEl.appendText(this.reviewState.numberOfItems.toString());
this.createSetting("Sort order")
.controlEl.appendText(sortOrder === "due" ? "Due date" : "Retrievability");
this.addDueTotals();
this.addRetrievabilityTotals();
this.createSetting("Current review unit", { isHeading: true })
.setDesc("Content that is currently in review.")
if (sortOrder === "retrievability") {
this.addRetrievability();
//this.addRetrievabilityTotals();
this.addDue();
}
else {
this.addDue();
//this.addDueTotals();
this.addRetrievability();
}
const lastReviewDate = info.lastReview
this.createSetting("Last reviewed")
.controlEl.append(lastReviewDate === null ? "This is the first review." : createFragment(f => {
f.appendText(DateTime.toString(lastReviewDate));
f.createEl("br");
f.appendText(DateTime.diffString(this.now, lastReviewDate));
}));
this.createSetting("Scheduled days")
.setDesc("The number of days between the last rating and the next determined due date. Also known as the repetition interval.")
.controlEl.appendText(`${stats.sd}`);
this.createSetting("Stage")
.setDesc(createFragment(f => {
f.createEl("p", { text: "This review units current stage in the learning process. One of New, Learning, Review, Relearning." });
}))
.controlEl.appendText(this.info.state);
this.createSetting("Ratings")
.setDesc("Total number of times this review unit has been rated.")
.controlEl.appendText(`${stats.r}`);
this.createSetting("Lapses")
.setDesc("Total number of times you have forgotten this review unit after it was considered learned. In other words, each time you rated Again when the state was Review.")
.controlEl.appendText(`${stats.l}`);
this.createSetting("Created")
.controlEl.appendText(info.created);
this.addDoneButton(true);
}
private addDue() {
const isDue = this.info.isStatisticsDue(this.reviewState.date);
this.createSetting("Due")
.setDesc(`This review units due date.`)
.controlEl.append(createFragment(f => {
f.appendText(this.info.due);
if (isDue) {
f.createEl("br");
f.appendText(DateTime.diffString(this.now, this.info.dueDate));
}
}));
};
private addDueTotals() {
if (!KeyValue.isNotEmpty(this.reviewState.totalDueBefore))
return;
const reviewDate = this.reviewState.date;
const entries = Array.from(this.reviewState.totalDueBefore.entries());
const el = this.createSetting("Ratings left")
.setDesc("The number of review units left in the current review queue.");
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
entries.forEach(([date, value], index) => {
let timeString: string;
const hours = date.getHours();
const minutes = date.getMinutes();
const today = new Date(reviewDate);
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(tomorrow.getDate() + 1);
const isToday = date.getTime() >= today.getTime() && date.getTime() < tomorrow.getTime();
const isTomorrow = date.getTime() >= tomorrow.getTime() && date.getTime() < dayAfterTomorrow.getTime();
if (isToday && hours === 12 && minutes === 0) {
timeString = "Before noon:";
} else if (isToday && hours === 20 && minutes === 0) {
timeString = "Before 8 this evening:";
} else if (isTomorrow && hours === 4 && minutes === 0) {
timeString = "Before 4 tomorrow morning:";
} else {
timeString = DateTime.toTimeString(date);
}
rowCreator.add(colCreator => {
colCreator.add({ text: timeString });
colCreator.add({ text: `${Str.NON_BREAKING_SPACE}${value}` });
});
});
});
}
private addRetrievability() {
// Retrievability is the statistical likelihood that you will have a successful recall, which in practice means you will end up rating the card 'Hard', 'Good', or 'Easy' — and not 'Again'
const el = this.createSetting("Retrievability")
.setDesc("The estimated probability that you will be able to recall this review unit right now, i.e., the likelihood you will rate Hard, Good, or Easy.");
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
rowCreator.add((col) => {
col.add({ text: "Now:" });
col.add({ text: `${Str.NON_BREAKING_SPACE}${this.info.retrievabilityAsString(this.reviewState.date)}` });
});
rowCreator.add((col) => {
col.add({ text: "At due date:" });
col.add({ text: `${Str.NON_BREAKING_SPACE}${this.info.retrievabilityAsString(this.info.dueDate)}` });
});
});
};
private addRetrievabilityTotals() {
if (!KeyValue.isNotEmpty(this.reviewState.totalRetrievabilityBelow))
return;
const entries = Array.from(this.reviewState.totalRetrievabilityBelow.entries());
const el = this.createSetting("Ratings left")
.setDesc("The number of review units left in the current review queue below the given retrievability.");
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
entries.forEach(([threshold, value], index) => {
rowCreator.add((col) => {
col.add({ text: `Below ${threshold * 100}%:` });
col.add({ text: `${Str.NON_BREAKING_SPACE}${value}` });
});
});
});
};
}

View file

@ -1,5 +1,6 @@
import { SuggestModal, App } from "obsidian";
import { DeckIDDataTuple, DataStore } from "DataStore";
import { DataStore, DeckIDDataTuple } from "data/DataStore";
import { App, SuggestModal } from "obsidian";
import { UIAssistant } from "ui/UIAssistant";
export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
@ -22,7 +23,7 @@ export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
}
renderSuggestion(value: DeckIDDataTuple, el: HTMLElement): void {
const numberOfCards = this.data.getAllCardsForDeck(value.id).length;
const numberOfCards = this.data.getAllCardsForDeck(value.id === UIAssistant.DECK_ID_NONE ? undefined : value.id).length;
el.createEl('div', { text: `${value.data.n}` }).createEl('small', { text: ` (${numberOfCards})` });
el.createEl('small', { text: value.data.p.length > 0 ? this.descendants(value) : "" });
}

View file

@ -1,48 +0,0 @@
export class MarkupAssistant {
public static createWrappedPara(parent: HTMLElement, o?: DomElementInfo | string, callback?: (el: HTMLParagraphElement) => void): HTMLParagraphElement {
return MarkupAssistant.createElWrapper(parent, "p", false).createEl("p", o, callback);
}
public static createWrappedEl<K extends keyof HTMLElementTagNameMap>(parent: HTMLElement, tag: K, o?: DomElementInfo | string, callback?: (el: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
return MarkupAssistant.createElWrapper(parent, tag).createEl(tag, o, callback);
}
/**
* Mimics how Obsidian/CodeMirror adds wrapper elements. For example, a `<p>` is wrapped with `<div class="el-p">`.
*
* @param appendPara Sometimes a `<p>` is added as an additional child; for example, if the second starting tag after the first is on the same line as the first. This param serves as documentation of this behavior, which is considered abnormal.
* @returns A created wrapper element for the given {@link tag}.
*/
public static createElWrapper<K extends keyof HTMLElementTagNameMap>(parent: HTMLElement, tag: K, appendPara = false) {
// If the second starting tag after the first is on the same line as the first (e.g., <audio ..></audio> or <audio ..><source ..>\n</audio>),
// it's wrapped `<div class="el-p"><p dir="auto">`;
// otherwise `<div class="el-tagname">`.
//
// Nevertheless, it's always wrapped in a div.
// - audio, video, blockquote, p, pre, details, ...
if (appendPara)
return MarkupAssistant.createWrappedPara(parent, { attr: { dir: "auto" } });
else
return parent.createDiv({ cls: MarkupAssistant.classForEl(tag) });
}
/**
* @param tag The tag name at the root of {@link html}.
* @param html The HTML string to wrap.
* @param appendPara See {@link createElWrapper}.
* @returns
*/
public static createElWrapperHtml<K extends keyof HTMLElementTagNameMap>(tag: K, html: string, appendPara = false) {
if (appendPara)
return `<div class="${MarkupAssistant.classForEl("p")}"><p>${html}</p></div>`;
else
return `<div class="${MarkupAssistant.classForEl(tag)}">${html}</div>`;
}
public static classForEl<K extends keyof HTMLElementTagNameMap>(tag: K) {
return "el-" + tag;
}
}

View file

@ -1,6 +1,6 @@
import { Env } from "env";
import { Component } from "obsidian";
import { HtmlAttribute } from "renderings/dom-constants";
import { HtmlAttribute } from "utils/dom/constants";
import { parseStrictFloat } from "TypeAssistant";
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
@ -201,24 +201,25 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
}
/**
* Converts a time string in HH:MM:SS.mmm format to seconds.
* @param timeString The time string (e.g., "00:01:30", or "00:01:30.432").
* @returns The time in seconds.
*/
* Converts a time string in HH:MM:SS.mmm format to seconds.
* @param timeString The time string (e.g., "00:01:30", or "00:01:30.432").
* @returns The time in seconds.
*/
private static timeToSeconds(timeString: string): number {
let totalSeconds = 0;
let milliseconds = 0;
// First, check for and extract milliseconds if present
const msParts = timeString.split('.');
const msParts = timeString.split(".");
if (msParts.length > 1) {
timeString = msParts[0]; // The part before milliseconds
timeString = msParts[0] ?? ""; // The part before milliseconds
// Convert milliseconds string to number, handle cases like ".4" or ".40"
milliseconds = Number(`0.${msParts[1]}`) || 0;
const msPartValue = msParts[1];
milliseconds = msPartValue !== undefined ? Number(`0.${msPartValue}`) : 0;
}
// Split the remaining time string by ':'
const parts = timeString.split(':').map(Number);
const parts = timeString.split(":").map(Number);
// Parse parts from right to left (most common for time formats like HH:MM:SS, MM:SS)
// index 0: seconds (if only 1 part) or hours (if 3 parts)
@ -229,20 +230,25 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
// "01:20" means 1 minute 20 seconds.
// "01:02:20" means 1 hour 2 minutes 20 seconds.
if (parts.length === 3) {
const firstPart = parts[0];
const secondPart = parts[1];
const thirdPart = parts[2];
if (firstPart !== undefined && secondPart !== undefined && thirdPart !== undefined) {
// HH:MM:SS
totalSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
totalSeconds = firstPart * 3600 + secondPart * 60 + thirdPart;
} else if (firstPart !== undefined && secondPart !== undefined) {
// MM:SS
totalSeconds = parts[0] * 60 + parts[1];
} else if (parts.length === 1) {
totalSeconds = firstPart * 60 + secondPart;
} else if (firstPart !== undefined) {
// SS (single number implies seconds for media fragments)
totalSeconds = parts[0];
totalSeconds = firstPart;
}
// Add the fractional seconds from milliseconds
totalSeconds += milliseconds;
Env.log.proc(`\ttimeToSeconds:\n\t\tinput: "${timeString}${msParts.length > 1 ? `.${msParts[1]}` : ''}", output ${totalSeconds}`);
const originalMsPart = msParts[1] ?? "";
Env.log.proc(`\ttimeToSeconds:\n\t\tinput: "${timeString}${originalMsPart ? `.${originalMsPart}` : ""}", output ${totalSeconds}`);
return totalSeconds;
}

View file

@ -1,12 +1,13 @@
import { App, Component, MarkdownRenderer, TFile } from "obsidian";
import { AudioProcessor } from "./AudioProcessor";
import { ContentProcessorConfig, ContentRendererProcessor, PostProcessorParameter, PreProcessorParameter } from "./ContentRendererProcessor";
import { HtmlElementWrapperProcessor } from "./HtmlElementWrapperProcessor";
import { LinkProcessor } from "./LinkProcessor";
import { VideoProcessor } from "./VideoProcessor";
import { Env } from "env";
import { RecycleComponent } from "renderings/RecycleComponent";
import { App, Component, MarkdownRenderer, TFile } from "obsidian";
import { AudioProcessor } from "renderings/content/AudioProcessor";
import { ContentProcessorConfig, ContentRendererProcessor, PostProcessorParameter, PreProcessorParameter } from "renderings/content/ContentRendererProcessor";
import { HtmlElementWrapperProcessor } from "renderings/content/HtmlElementWrapperProcessor";
import { LinkProcessor } from "renderings/content/LinkProcessor";
import { VideoProcessor } from "renderings/content/VideoProcessor";
import { RecycleComponent } from "utils/obs/RecycleComponent";
import { PluginSettings } from "Settings";
import { Async } from "utils/ts";
export function createRenderConfig(settings: PluginSettings): ContentProcessorConfig {
return {
@ -38,16 +39,18 @@ export class ContentRenderer extends RecycleComponent {
private customProcessors: ContentRendererProcessor[] = [];
public constructor(app: App, config: ContentProcessorConfig) {
Env.log.d("ContentRenderer:constructor");
super();
this.app = app;
this.config = config;
}
protected onRecycled(component: Component): void {
Env.log.d("ContentRenderer:onRecycled");
/** These will always run, and before any custom ones. */
this.defaultProcessors = [
new HtmlElementWrapperProcessor({ applyLangTagsToNonLatinScripts: false }),
new HtmlElementWrapperProcessor({ applyLangTagsToNonLatinScripts: true }),
new LinkProcessor(),
new AudioProcessor(),
new VideoProcessor(),
@ -57,12 +60,14 @@ export class ContentRenderer extends RecycleComponent {
}
protected onRecycling(): void {
Env.log.d("ContentRenderer:onRecycling");
this.defaultProcessors = [];
this.customProcessors = [];
}
/** Set processes that will be used in all following calls to {@link render}, replacing any previous ones. Note that these will get unloaded and removed on recycling and therefore, if desired, need to be created and set again ahead of any following call to {@link render}. */
public setCustomProcessors(processors: ContentRendererProcessor[]) {
Env.log.d("ContentRenderer:setCustomProcessors");
this.customProcessors.forEach(p => this.recycleComponent.removeChild(p));
this.customProcessors = processors;
this.customProcessors.forEach(p => this.recycleComponent.addChild(p));
@ -70,6 +75,7 @@ export class ContentRenderer extends RecycleComponent {
/** Note that these will get unloaded and removed on recycling and therefore, if desired, need to be created and set again ahead of any following call to {@link render}. */
public addCustomProcessors(processors: ContentRendererProcessor[]) {
Env.log.d("ContentRenderer:addCustomProcessors");
processors.forEach(p => {
this.customProcessors.push(p);
this.recycleComponent.addChild(p);
@ -78,6 +84,7 @@ export class ContentRenderer extends RecycleComponent {
/** It is only necessary to remove processors if you do not want to use them during the next call to {@link render}. */
public removeCustomProcessors(processors: ContentRendererProcessor[]) {
Env.log.d("ContentRenderer:removeCustomProcessors");
processors.forEach(p => {
this.customProcessors.remove(p);
this.recycleComponent.removeChild(p);
@ -85,15 +92,19 @@ export class ContentRenderer extends RecycleComponent {
}
/**
*
* @param markdown
* @param el
* @param options Values set here are only valid during this rendering pass. Processors are unloaded.
* @returns
* @param options
* @throws Throws a {@link TimeoutError} if the promise does not settle within the specified timeout (default 4000ms).
*/
public async render(markdown: string, el: HTMLElement, options?: { sourcePath?: string, processors?: ContentRendererProcessor[] }): Promise<void> {
const sourcePath = options?.sourcePath ?? this.file?.path;
const processors: ContentRendererProcessor[] = options?.processors ?? [];
public async render(markdown: string, el: HTMLElement, options?: { sourcePath?: string, processors?: ContentRendererProcessor[], timeoutMs?: number }): Promise<void> {
const {
sourcePath = this.file?.path,
processors = [],
timeoutMs = 4000,
} = options ?? {};
Env.log.d("ContentRenderer:render: processors", processors, sourcePath);
if (!sourcePath) {
el.createSpan({ text: "Failed to render." });
@ -124,15 +135,17 @@ export class ContentRenderer extends RecycleComponent {
// default run first
const allProcessors = [...this.defaultProcessors, ...this.customProcessors, ...processors];
Env.log.view(`Running ${allProcessors.filter(p => p.isPreProcessor()).length} pre processors: ${allProcessors.filter(p => p.isPreProcessor()).map(a => a.constructor.name).join(", ")}`);
Env.log.view(`ContentRenderer:render: Running ${allProcessors.filter(p => p.isPreProcessor()).length} pre processors: ${allProcessors.filter(p => p.isPreProcessor()).map(a => a.constructor.name).join(", ")}`);
for (const processor of allProcessors) {
if (processor.isPreProcessor())
processor.handleMarkdown(preParameter);
}
await MarkdownRenderer.render(this.app, preParameter.markdown, el, sourcePath, this.recycleComponent);
Env.log.view("ContentRenderer:render: calling `MarkdownRenderer`, timeout", timeoutMs);
await Async.withTimeout(MarkdownRenderer.render(this.app, preParameter.markdown, el, sourcePath, this.recycleComponent), timeoutMs);
Env.log.view("\tRendering done.");
Env.log.view(`Running ${allProcessors.filter(p => p.isPostProcessor()).length} post processors: ${allProcessors.filter(p => p.isPostProcessor()).map(a => a.constructor.name).join(", ")}`);
Env.log.view(`ContentRenderer:render: Running ${allProcessors.filter(p => p.isPostProcessor()).length} post processors: ${allProcessors.filter(p => p.isPostProcessor()).map(a => a.constructor.name).join(", ")}`);
for (const processor of allProcessors) {
if (processor.isPostProcessor())
processor.handleHtml(postParameter);

View file

@ -1,5 +1,6 @@
import { Env } from "env";
import { App, Component } from "obsidian";
import { getDoc } from "utils/obs/dom";
export type MediaContentProcessorConfig = {
preventMultiplePlayback: boolean;
@ -42,12 +43,12 @@ export abstract class ContentRendererProcessor extends Component {
}
public override onload(): void {
Env.log.view(`ContentRendererPostProcessor:onload: `+ this.constructor.name);
Env.log.d("ContentRendererPostProcessor:onload:", this.constructor.name);
super.onload();
}
public override onunload(): void {
Env.log.view(`ContentRendererPostProcessor:onunload: `+ this.constructor.name);
Env.log.d("ContentRendererPostProcessor:onunload:", this.constructor.name);
super.onunload();
}
@ -63,3 +64,20 @@ export abstract class ContentRendererProcessor extends Component {
}
}
}
export class ContentRendererPostProcessorAssistant {
public param: PostProcessorParameter;
constructor(param: PostProcessorParameter) {
this.param = param;
}
public get doc() {
return getDoc(this.param.el);
}
public get el() {
return this.param.el;
}
}

View file

@ -1,4 +1,5 @@
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "renderings/content/ContentRendererProcessor";
import { UnexpectedUndefinedError } from "utils/errors";
export class HeadingProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor {
private readonly baseHeadingLevel: number;
@ -70,6 +71,8 @@ export class HeadingProcessor extends ContentRendererProcessor implements Conten
// Copy attributes
for (let i = 0; i < heading.attributes.length; i++) {
const attr = heading.attributes[i];
if (attr === undefined)
throw new UnexpectedUndefinedError();
newHeading.setAttribute(attr.name, attr.value);
}

View file

@ -1,38 +1,109 @@
import { MarkupAssistant } from "renderings/MarkupAssistant";
import { ContentRendererPreProcessor, ContentRendererProcessor, PreProcessorParameter } from "./ContentRendererProcessor";
import { HtmlAttribute, HtmlTag } from "utils/dom/constants";
import { createElWrapperHtml } from "utils/obs/dom";
import { ContentRendererPostProcessor, ContentRendererPostProcessorAssistant, ContentRendererPreProcessor, ContentRendererProcessor, PostProcessorParameter, PreProcessorParameter } from "./ContentRendererProcessor";
import { Env } from "env";
export type HtmlElementWrapperProcessorConfig = {
applyLangTagsToNonLatinScripts: boolean;
}
/**
* Processes inline HTML in the markdown string.
*/
export class HtmlElementWrapperProcessor extends ContentRendererProcessor implements ContentRendererPreProcessor {
/** Wraps specific HTML elements. */
export class HtmlElementWrapperProcessor extends ContentRendererProcessor implements ContentRendererPreProcessor, ContentRendererPostProcessor {
private config: HtmlElementWrapperProcessorConfig;
constructor(config: HtmlElementWrapperProcessorConfig) {
Env.log.d("HtmlElementWrapperProcessor:constructor");
super();
this.config = config;
}
public handleMarkdown(param: PreProcessorParameter): void {
Env.log.d("HtmlElementWrapperProcessor:handleMarkdown");
let content = param.markdown;
content = content.replace(ELEMENT_REGEX, (html, tag) => {
return MarkupAssistant.createElWrapperHtml(tag, html);
return 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>`);
handleHtml(param: PostProcessorParameter): void {
Env.log.d("HtmlElementWrapperProcessor:handleHtml");
const assistant = new ContentRendererPostProcessorAssistant(param);
if (this.config.applyLangTagsToNonLatinScripts)
this.applyLangTagsToNonLatinScripts(assistant);
}
/**
* Traverses the DOM tree of a given HTML element, finds text nodes containing
* consecutive Thai characters, and wraps those character sequences in a
* `<span>` element with the `lang="th"` attribute.
*
* @param element The root HTMLElement to start the traversal from.
*/
private applyLangTagsToNonLatinScripts(assistant: ContentRendererPostProcessorAssistant): void {
Env.log.d("HtmlElementWrapperProcessor:applyLangTagsToNonLatinScripts");
const doc = assistant.doc;
const walker = doc.createTreeWalker(assistant.el, NodeFilter.SHOW_TEXT, null);
const textNodes: Node[] = [];
let node;
while ((node = walker.nextNode()))
textNodes.push(node)
const SCRIPT = "SCRIPT";
const STYLE = "STYLE";
const SPAN = "SPAN";
textNodes.forEach(textNode => {
const parentElement = textNode.parentElement;
if (!parentElement)
return;
const text = textNode.textContent;
if (!text)
return;
if (parentElement.tagName === SCRIPT || parentElement.tagName === STYLE)
return;
if (parentElement.tagName === SPAN && parentElement.getAttribute(HtmlAttribute.Lang.NAME) === HtmlAttribute.Lang.Values.THAI)
return;
const matches = [...text.matchAll(THAI_REGEX)];
if (matches.length > 0) {
const fragment = doc.createDocumentFragment();
let lastIndex = 0;
matches.forEach(match => {
const matchText = match[0];
const matchIndex = match.index!;
if (matchIndex > lastIndex) {
const beforeText = text.slice(lastIndex, matchIndex);
fragment.appendChild(doc.createTextNode(beforeText));
}
const span = doc.createElement(HtmlTag.SPAN);
span.setAttribute(HtmlAttribute.Lang.NAME, HtmlAttribute.Lang.Values.THAI);
span.textContent = matchText;
fragment.appendChild(span);
lastIndex = matchIndex + matchText.length;
});
if (lastIndex < text.length) {
const remainingText = text.slice(lastIndex);
fragment.appendChild(doc.createTextNode(remainingText));
}
parentElement.replaceChild(fragment, textNode);
}
});
}
}
@ -42,7 +113,5 @@ export class HtmlElementWrapperProcessor extends ContentRendererProcessor implem
*/
const ELEMENT_REGEX = /<(audio|video)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1>)/gi;
/**
* - Matches consecutive Thai characters.
*/
/** Matches consecutive Thai characters. */
const THAI_REGEX = /[\u0E00-\u0E7F]+/g;

View file

@ -1,4 +1,4 @@
import { HtmlAttribute } from "renderings/dom-constants";
import { HtmlAttribute } from "utils/dom/constants";
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
export class VideoProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor {

View file

@ -1,4 +1,4 @@
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
import { AlternateHeadingsDeclarable, AlternateHeadingsAssistant } from "declarations/commands/AlternateHeadings";
export class AlternateHeadingsRenderer

View file

@ -1,4 +1,4 @@
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
import { CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
export class CardDeclarationRenderer

View file

@ -1,4 +1,4 @@
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "./DeclarationRenderable";
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/declarations/DeclarationRenderable";
import { Declarable } from "declarations/Declaration";

View file

@ -1,15 +1,15 @@
import { CardDeclarationAssistant } from "declarations/CardDeclaration";
import { CommandDeclarationAssistant } from "declarations/CommandDeclaration";
import { Declaration } from "declarations/Declaration";
import { MarkdownRenderChild, setIcon } from "obsidian";
import { AlternateHeadingsRenderer } from "./AlternateHeadingsRenderer";
import { CardDeclarationRenderer } from "./CardDeclarationRenderer";
import { DeclarationErrorRenderer } from "./DeclarationErrorRenderer";
import { DataProvider, DeclarationChangedEvent, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
import { HeadingAndDelimiterRenderer } from "./HeadingAndDelimiterRenderer";
import { HeadingIsFrontRenderer } from "./HeadingIsFrontRenderer";
import { PLUGIN_ICON } from "UIAssistant";
import { Env } from "env";
import { MarkdownRenderChild, setIcon } from "obsidian";
import { AlternateHeadingsRenderer } from "renderings/declarations/AlternateHeadingsRenderer";
import { CardDeclarationRenderer } from "renderings/declarations/CardDeclarationRenderer";
import { DeclarationErrorRenderer } from "renderings/declarations/DeclarationErrorRenderer";
import { DataProvider, DeclarationChangedEvent, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
import { HeadingAndDelimiterRenderer } from "renderings/declarations/HeadingAndDelimiterRenderer";
import { HeadingIsFrontRenderer } from "renderings/declarations/HeadingIsFrontRenderer";
import { Icon } from "ui/constants";
export class DeclarationRenderChild extends MarkdownRenderChild {
@ -86,7 +86,7 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
this.containerEl.addClass("callout");
this.titleContainer = this.containerEl.createDiv({ cls: "callout-title" });
this.titleContainer.createDiv({ cls: "callout-icon" }, (icon) => setIcon(icon, PLUGIN_ICON));
this.titleContainer.createDiv({ cls: "callout-icon" }, (icon) => setIcon(icon, Icon.PLUGIN));
this.titleEl = this.titleContainer.createDiv({ cls: "callout-title-inner" });
this.contentContainerEl = this.containerEl.createDiv({ cls: "callout-content" });

View file

@ -1,7 +1,7 @@
import { Component, setIcon } from "obsidian";
import { UIAssistant } from "UIAssistant";
import { DeckIDDataTuple } from "data/DataStore";
import { DeckableDeclarable, Declarable } from "declarations/Declaration";
import { DeckIDDataTuple } from "DataStore";
import { Component, setIcon } from "obsidian";
import { UIAssistant } from "ui/UIAssistant";
export type DeclarationChangedType = "deckAdded" | "deckChanged";
export type DeclarationChangedEvent = (declaration: DeckableDeclarable, type: DeclarationChangedType, selectEl: HTMLSelectElement) => void;
@ -89,7 +89,7 @@ export class DeclarationRenderAssistant {
const deckSelectEl = tdDropdown.createEl("select", { cls: "dropdown" }, (el) => {
el.createEl("option", {
text: "None",
value: UIAssistant.DECK_ID_UNDEFINED,
value: UIAssistant.DECK_ID_NONE,
});
});

View file

@ -1,4 +1,4 @@
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "declarations/commands/HeadingAndDelimiter";
export class HeadingAndDelimiterRenderer

View file

@ -1,5 +1,5 @@
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "declarations/commands/HeadingIsFront";
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "./DeclarationRenderable";
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/declarations/DeclarationRenderable";
export class HeadingIsFrontRenderer
extends DeclarationRenderer<HeadingIsFrontDeclarable>

67
src/scheduling/Fsrs.ts Normal file
View file

@ -0,0 +1,67 @@
import { Env } from "env";
import { FsrsSchedulerConfig } from "scheduling/types";
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, RecordLogItem } from "ts-fsrs";
export class Fsrs {
public static readonly DefaultParameter = {
ENABLE_FUZZ: default_enable_fuzz,
} as const;
private readonly fsrs: FSRS;
constructor(config: FsrsSchedulerConfig) {
this.fsrs = Fsrs.createFromConfig(config);
}
private static createFromConfig(config: FsrsSchedulerConfig) {
Env.assert(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 static createEmptyCard(): Card {
return createEmptyCard(new Date());
}
/**
* @param scheduleDate The date for the scheduling. Omit to schedule at the time of call (recommended).
*/
public rate(rating: Grade, card: Card, scheduleDate?: Date): RecordLogItem {
return this.fsrs.next(card, scheduleDate ?? new Date(), rating);
}
public previewNext(card: Card, reviewDate?: Date): RecordLogItem[] {
const preview = this.fsrs.repeat(card, reviewDate ?? new Date());
const logItems: RecordLogItem[] = [];
for (const item of preview)
logItems.push(item);
return logItems;
}
// private nextState(rating: Rating, card?: Card) {
// const nextMemoryState = this.fsrs.next_state(
// card !== undefined ? { stability: card.stability, difficulty: card.difficulty } : null,
// card !== undefined ? card.elapsed_days : 0,
// rating,
// );
// }
public calculateRetrievability(card: Card, reviewDate: Date): number {
return this.fsrs.get_retrievability(card, reviewDate, false);
}
public calculateRetrievabilityString(card: Card, reviewDate: Date): string {
return this.fsrs.get_retrievability(card, reviewDate, true);
}
}

View file

@ -0,0 +1,95 @@
import { StatisticsData } from 'data/DataStore';
import { Rating } from 'scheduling/types';
import { Card, Grade as FsrsGrade, Rating as FsrsRating, State, TypeConvert } from 'ts-fsrs';
import { Str } from 'utils/ts';
export const FsrsConvert = {
/**
* A rating excluding `Rating.Manual` is named `Grade` in `ts-fsrs`. {@link Rating} wraps `Grade` and calls it `Rating`.
*
* @param rating
* @returns
*/
toGrade: (rating: Rating): FsrsGrade => {
switch (rating) {
case Rating.Again: return FsrsRating.Again;
case Rating.Hard: return FsrsRating.Hard;
case Rating.Good: return FsrsRating.Good;
case Rating.Easy: return FsrsRating.Easy;
}
},
/**
* @remarks
*
* `Rating.Manual` is for bypassing the algorithm.
* It's a special value used to indicate that a card's review schedule is being handled manually not by the FSRS algorithm
* to allow for manual intervention in the scheduling process.
*
* - The next_state method in the FSRSAlgorithm class, which is responsible for updating a card's difficulty and stability, does not perform any calculations when the rating is Manual. It simply returns the existing state.
* - The next method in the FSRS class, which is used for scheduling the next review, explicitly throws an error if the rating is Manual, preventing it from being used in a normal review.
* - The rollback method also throws an error if it encounters a Manual rating in a review log.
*
* @throws If {@link r} is `Rating.Manual`.
*/
asRating: (r: FsrsRating): Rating => {
switch (r) {
case FsrsRating.Again: return Rating.Again;
case FsrsRating.Hard: return Rating.Hard;
case FsrsRating.Good: return Rating.Good;
case FsrsRating.Easy: return Rating.Easy;
case FsrsRating.Manual:
throw new Error();
}
},
asCard: (s: StatisticsData): Card => {
return {
due: TypeConvert.time(s.due),
stability: s.s,
difficulty: s.d,
// TODO: Delete in ts-fsrs 6. Value not used.
elapsed_days: 0,
scheduled_days: s.sd,
learning_steps: s.ls,
reps: s.r,
lapses: s.l,
state: s.st as State,
// 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: Str.is(s.lr) ? TypeConvert.time(s.lr) : undefined,
};
},
asStatistics: (card: Card): StatisticsData => {
return {
due: card.due.toISOString(),
s: card.stability,
d: card.difficulty,
sd: card.scheduled_days,
ls: card.learning_steps,
r: card.reps,
l: card.lapses,
st: card.state,
lr: Str.toIsoStringOrNull(card.last_review),
} satisfies StatisticsData;
},
/**
* Updates the properties of a {@link StatisticsData} object with the values from a {@link Card} object.
* @param s The {@link StatisticsData} object to update.
* @param card The {@link Card} object to source the new values from.
*/
setStatistics: (s: StatisticsData, card: Card) => {
s.due = card.due.toISOString();
s.s = card.stability;
s.d = card.difficulty;
s.sd = card.scheduled_days;
s.ls = card.learning_steps;
s.r = card.reps;
s.l = card.lapses;
s.st = card.state;
s.lr = Str.toIsoStringOrNull(card.last_review);
},
}

View file

@ -0,0 +1,58 @@
import { CardData } from "data/DataStore";
import { Fsrs } from "scheduling/Fsrs";
import { Rating } from 'scheduling/types';
import { Card, Rating as FsrsRating, State as FsrsState, show_diff_message } from "ts-fsrs";
import { DateTime } from "utils/datetime";
const timeUnit = [" sec", " min", " hours", " days", " months", " years"];
const Convert = {
stateAsString: (state: number) => String(FsrsState[state]),
ratingAsString: (rating: Rating) => String(FsrsRating[rating]),
timeDiffMsg: (due: Date, lastReview: Date) => show_diff_message(due, lastReview, true, timeUnit),
} as const;
/** See {@link CardData.c} */
const CREATED_DATE_FALLBACK = "2025";
export class ReviewItemInfo {
private readonly fsrs: Fsrs;
private readonly card: Card;
private readonly cardData: CardData;
constructor(fsrs: Fsrs, card: Card, cardData: CardData) {
this.fsrs = fsrs;
this.card = card;
this.cardData = cardData;
}
public static readonly Convert = Convert;
public get created(): string {
return this.cardData.c !== null ? DateTime.dateStringFromIso(this.cardData.c) : CREATED_DATE_FALLBACK;
}
public get dueDate() {
return this.card.due;
}
public get due() {
return DateTime.toString(this.dueDate);
}
public get state() {
return Convert.stateAsString(this.card.state);
}
public get lastReview() {
return this.card.last_review ?? null;
}
public retrievabilityAsString(date: Date) {
return this.fsrs.calculateRetrievabilityString(this.card, date);
}
public isStatisticsDue(compareDate: Date = new Date()) {
return DateTime.isDateLater(this.card.due, compareDate);
}
}

325
src/scheduling/Scheduler.ts Normal file
View file

@ -0,0 +1,325 @@
import { CardIDDataTuple, DataStore, StatisticsData } from "data/DataStore";
import { FullID } from "data/FullID";
import { Env } from "env";
import { Fsrs } from "scheduling/Fsrs";
import { FsrsConvert } from "scheduling/FsrsConvert";
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
import { FsrsSchedulerConfig, NextItem, NextReviewItemOptions, Rating, ReviewItem } from "scheduling/types";
import { Card, State, TypeConvert } from "ts-fsrs";
import { KeyValue } from "utils/ts";
interface DataItem {
id: FullID;
card: Card;
}
interface RetreivabilityDataItem extends DataItem {
retreivability: number;
}
export class Scheduler {
private static readonly DEFAULT_CONFIG: FsrsSchedulerConfig = {
enableFuzz: Fsrs.DefaultParameter.ENABLE_FUZZ,
};
private readonly data: DataStore;
private fsrs: Fsrs;
public constructor(data: DataStore, config: FsrsSchedulerConfig = Scheduler.DEFAULT_CONFIG) {
this.data = data;
this.fsrs = new Fsrs(config);
}
public reconfigure(config: FsrsSchedulerConfig) {
this.fsrs = new Fsrs(config);
}
public createItem() {
return FsrsConvert.asStatistics(Fsrs.createEmptyCard());
}
/**
* @param id ID of the item to rate.
* @param rating
* @param scheduleDate The date for the scheduling. Omit to schedule at the time of call (recommended).
*/
public rateItem(id: FullID, rating: Rating, scheduleDate?: Date) {
this.data.editCard(id, (editor) => {
const recordLogItem = this.fsrs.rate(FsrsConvert.toGrade(rating), FsrsConvert.asCard(editor.data.s), scheduleDate);
FsrsConvert.setStatistics(editor.data.s, recordLogItem.card);
//this.data.log.unshift(recordLogItem.log);
return true;
});
}
public previewNextItem(data: StatisticsData, reviewDate?: Date) {
return this.previewNext(FsrsConvert.asCard(data), reviewDate);
}
/**
* @param id
* @throws If {@link id} does not exist.
*/
public previewNextItemByID(id: FullID, reviewDate?: Date) {
const cd = this.data.getCard(id, true)!;
const card = FsrsConvert.asCard(cd.s)
return this.previewNext(card, reviewDate);
}
private previewNext(card: Card, reviewDate?: Date): NextItem[] {
return this.fsrs
.previewNext(card, reviewDate)
.map(logItem => {
return {
stat: FsrsConvert.asStatistics(logItem.card),
reviewLog: {
rating: FsrsConvert.asRating(logItem.log.rating),
// Add properties as needed
}
} satisfies NextItem;
});
}
private static createDataItem(i: CardIDDataTuple) {
const card = FsrsConvert.asCard(i.data.s);
return {
id: i.id,
card: card,
} satisfies DataItem;
};
private static createRetrievabilityDataItem(cardData: CardIDDataTuple, retreivability: (card: Card) => number): RetreivabilityDataItem {
const item = Scheduler.createDataItem(cardData);
return {
...item,
...{
retreivability: retreivability(item.card),
},
} satisfies RetreivabilityDataItem;
};
/**
* Get the next review item from {@link cards}.
*
* Not necessarily deterministic.
*
* @param cards
* @param reviewDate
* @returns `null` if there is noting to review at {@link reviewDate}.
*/
public getNextItem(cards: CardIDDataTuple[], reviewDate: Date, options: NextReviewItemOptions): ReviewItem | null {
const retrievabilityUpperThreshold = options.retrievabilityUpperThreshold ?? 0.95;
let nextItem: DataItem | null = null;
switch (options.sortOrder) {
case "retrievability": {
const items = cards.map(i => Scheduler.createRetrievabilityDataItem(i, (card) => this.fsrs.calculateRetrievability(card, reviewDate)));
const itemsSortedByRetrievability = items.sort(Scheduler.Comparer.retrievability);
if (KeyValue.isNotEmpty(options.totalRetrievabilityBelow)) {
for (const threshold of options.totalRetrievabilityBelow.keys())
options.totalRetrievabilityBelow.set(threshold, itemsSortedByRetrievability.filter(i => i.retreivability < threshold).length);
}
nextItem = itemsSortedByRetrievability.find(i => i.retreivability < retrievabilityUpperThreshold) ?? null;
break;
}
case "due": {
const items = cards.map(i => Scheduler.createDataItem(i));
const itemsSortedByDueDate = items.sort(Scheduler.Comparer.dueDateAsc);
if (KeyValue.isNotEmpty(options.totalDueBefore)) {
for (const date of options.totalDueBefore.keys())
options.totalDueBefore.set(date, itemsSortedByDueDate.filter(i => this.isCardDue(i.card, date)).length);
}
nextItem = itemsSortedByDueDate.find(i => this.isCardDue(i.card, reviewDate)) ?? null;
break;
}
}
return nextItem === null ? null : {
id: nextItem.id,
statistics: FsrsConvert.asStatistics(nextItem.card),
} satisfies ReviewItem;
}
public getItemInfo(item: ReviewItem) {
const c = this.data.getCard(item.id, false);
Env.dev?.assert(c !== null, "Expected existing id:", item.id.toString());
return new ReviewItemInfo(this.fsrs, FsrsConvert.asCard(item.statistics), c!);
}
/**
*
* @param lastState
* @param groupedByStateSortedByDueDate Cards with {@link State.Relearning} are expected to be merged with {@link State.Learning}.
* @returns If {@link State.Learning} is returned, cards in {@link State.Relearning} state may also be used.
*/
private getNextStateToUse(date: Date, lastState: State, groupedByStateSortedByDueDate: Record<State, {
id: FullID;
card: Card;
}[]>): State {
const hasNewItems = groupedByStateSortedByDueDate[State.New].length > 0;
const firstLearningItem = groupedByStateSortedByDueDate[State.Learning][0];
const hasRelearningItems = groupedByStateSortedByDueDate[State.Relearning].length > 0;
const hasReviewItems = groupedByStateSortedByDueDate[State.Review].length > 0;
if (hasRelearningItems)
throw new Error("Internal Error");
let nextState = State.New;
switch (lastState) {
case State.New:
if (firstLearningItem !== undefined)
nextState = State.Learning;
else if (hasReviewItems)
nextState = State.Review;
break;
case State.Learning:
case State.Relearning:
if (hasReviewItems)
nextState = State.Review;
else if (hasNewItems)
nextState = State.New;
else
nextState = State.Learning;
break;
case State.Review:
if (firstLearningItem !== undefined)
nextState = State.Learning;
else if (hasNewItems)
nextState = State.New;
else
nextState = State.Review;
break;
}
// If next state is learning/relearning and there are cards in those states due, go ahead.
// The first card in the array is expected to be due next.
if (nextState === State.Learning && firstLearningItem !== undefined && this.isCardDue(firstLearningItem.card, date)) {
return State.Learning;
}
else {
if (!hasNewItems)
return State.Review
else if (!hasReviewItems)
return State.New
else
return Math.random() > 0.5 ? State.Review : State.New
}
}
private groupItemsByState(items: DataItem[], groupRelearningAsLearning: boolean, excludeItem?: DataItem) {
const grouped: Record<State, DataItem[]> = {
[State.New]: [],
[State.Learning]: [],
[State.Relearning]: [],
[State.Review]: [],
};
for (const item of items) {
if (excludeItem !== undefined && excludeItem.id.isEqual(item.id))
continue;
if (groupRelearningAsLearning && item.card.state === State.Relearning)
grouped[State.Learning].push(item);
else
grouped[item.card.state as State].push(item);
}
return grouped;
}
/**
* @param card
* @param date The {@link Date} to compare against. If later than {@link card}, then the latter is due.
* @returns
*/
public isCardDue(card: Card, date: Date) {
return Scheduler.isDateLater(card.due, date);
}
public isStatisticsDue(data: StatisticsData, compareDate: Date = new Date()) {
return Scheduler.isDateLater(TypeConvert.time(data.due), compareDate);
}
/**
*
* @param date
* @param compareDate
* @returns `true` if {@link compareDate} is later than {@link date}.
*/
public static isDateLater(date: Date | string, compareDate: Date = new Date()): boolean {
if (typeof date === 'object' && date instanceof Date) {
return compareDate.getTime() - date.getTime() > 0 ? true : false;
} else {
return this.isDateLater(TypeConvert.time(date), compareDate);
}
}
private static Comparer = class {
/**
* Sort by last reviewed.
*
* Sort by the date an item was latest reviewed descending,
* i.e., the item with the latest last review date will be sorted first.
*
* Items without a last review date will be sorted last as they have not been reviewed.
*/
public static lastReviewDateDesc(a: DataItem, b: DataItem): number {
if (b.card.last_review !== undefined && a.card.last_review !== undefined)
return TypeConvert.time(b.card.last_review).getTime() - TypeConvert.time(a.card.last_review).getTime();
if (b.card.last_review !== undefined && a.card.last_review === undefined)
return 1;
if (b.card.last_review === undefined && a.card.last_review !== undefined)
return -1;
return 0;
}
public static dueDateAsc(a: DataItem, b: DataItem): number {
return TypeConvert.time(a.card.due).getTime() - TypeConvert.time(b.card.due).getTime();
}
public static retrievability(a: RetreivabilityDataItem, b: RetreivabilityDataItem) {
if (a.retreivability < b.retreivability) return -1;
if (a.retreivability > b.retreivability) return 1;
return 0;
}
public static newest(a: DataItem, b: DataItem): number {
if (a.card.state == State.New && b.card.state == State.New)
return Scheduler.Comparer.dueDateAsc(a, b);
if (a.card.state == State.New && b.card.state != State.New)
return 1;
if (a.card.state != State.New && b.card.state == State.New)
return -1
const aIsLearningOrRelearning = a.card.state == State.Learning || a.card.state == State.Relearning;
const bIsLearningOrRelearning = b.card.state == State.Learning || b.card.state == State.Relearning;
if (aIsLearningOrRelearning && bIsLearningOrRelearning)
return Scheduler.Comparer.dueDateAsc(a, b);
if (aIsLearningOrRelearning && !bIsLearningOrRelearning)
return 1;
if (!aIsLearningOrRelearning && bIsLearningOrRelearning)
return -1;
return Scheduler.Comparer.dueDateAsc(a, b);
}
}
}

38
src/scheduling/types.ts Normal file
View file

@ -0,0 +1,38 @@
import { StatisticsData } from "data/DataStore";
import { FullID } from "data/FullID";
import { Rating as FsrsRating } from 'ts-fsrs';
export enum Rating {
Again = FsrsRating.Again,
Hard = FsrsRating.Hard,
Good = FsrsRating.Good,
Easy = FsrsRating.Easy
}
export const Ratings = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy];
export interface NextItem {
stat: StatisticsData,
reviewLog: {
rating: Rating,
// Add properties as needed
},
};
export type ReviewSortOrder = "due" | "retrievability";
export type FsrsSchedulerConfig = {
enableFuzz: boolean;
}
export type NextReviewItemOptions = {
sortOrder: ReviewSortOrder;
totalDueBefore?: Map<Date, number>;
totalRetrievabilityBelow?: Map<number, number>;
/** When sorting by retrievability, do not return items with higher than this. */
retrievabilityUpperThreshold?: number;
}
export interface ReviewItem {
id: FullID,
statistics: StatisticsData,
};

17
src/types.ts Normal file
View file

@ -0,0 +1,17 @@
// types.ts - Global type declarations
// Global type aliases
/**
* Creates a "strict" version of a type by removing its index signature.
* This is useful for enforcing excess property checks on types that have an index signature.
*/
export type OmitIndexSignature<T> = {
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export type UnsignedInteger = number & { readonly __brand: "UnsignedInteger" };

52
src/ui/SettingTab.ts Normal file
View file

@ -0,0 +1,52 @@
import t from "Localization";
import { Plugin, PluginSettingTab, Setting } from "obsidian";
import { SettingsChanged, SettingsManager } from "Settings";
export class SettingTab extends PluginSettingTab {
private settingsManager: SettingsManager;
public constructor(plugin: Plugin, settingsManager: SettingsManager) {
super(plugin.app, plugin);
this.settingsManager = settingsManager;
}
private onChangedCallback: SettingsChanged = (_, isExternal) => {
if (isExternal)
this.display();
}
public display(): void {
this.settingsManager.registerOnChangedCallback(this.onChangedCallback);
const { containerEl } = this;
const settings = this.settingsManager.settings;
containerEl.empty();
new Setting(containerEl)
.setName(t.settings.uiPrefix.name)
.setDesc(t.settings.uiPrefix.description)
.addText((component) => {
component.setValue(settings.uiPrefix);
component.onChange(async (value) => {
settings.uiPrefix = value;
await this.settingsManager.save();
});
});
new Setting(containerEl)
.setName(t.settings.hideCardHeadingInReview.name)
.setDesc(t.settings.hideCardHeadingInReview.description)
.addToggle((component) => {
component.setValue(settings.hideCardSectionMarker);
component.onChange(async (value) => {
settings.hideCardSectionMarker = value;
await this.settingsManager.save();
});
});
}
public hide(): void {
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
}
}

View file

@ -1,19 +1,17 @@
import { DeckIDDataTuple } from "data/DataStore";
import { DeckID } from "data/FullID";
import { App, MarkdownView, Menu, MenuItem, Notice } from "obsidian";
import { SettingsManager } from "Settings";
import { DeckIDDataTuple } from "DataStore";
import { Icon, PLUGIN_NAME } from "ui/constants";
export const PLUGIN_NAME = "Come Through";
export const PLUGIN_ICON = "drill";
export const CARD_FRONT_ICON = "file-output";
export const CARD_BACK_ICON = "file-input";
export class UIAssistant {
/**
* Deck ID to use to represent unassigned deck when a string is required to identify UI elements.
* See, e.g, {@link allDecksOptionItem}.
* For example string values in DOM elements.
*/
public static readonly DECK_ID_UNDEFINED = "";
public static readonly DECK_ID_NONE: DeckID = "0000";
private settingsManager: SettingsManager;
@ -62,7 +60,7 @@ export class UIAssistant {
section,
checked,
onClick,
icon = PLUGIN_ICON,
icon = Icon.PLUGIN,
prefix = true,
isLabel = false,
} = options || {};
@ -105,11 +103,11 @@ export class UIAssistant {
return notice;
}
public static allDecksOptionItem(includeDefaultDeck?: string): DeckIDDataTuple {
public static allDecksOptionItem(): DeckIDDataTuple {
return {
id: UIAssistant.DECK_ID_UNDEFINED,
id: UIAssistant.DECK_ID_NONE,
data: {
n: typeof includeDefaultDeck === "string" ? includeDefaultDeck : "All Decks",
n: "All Decks",
p: []
}
};

7
src/ui/constants.ts Normal file
View file

@ -0,0 +1,7 @@
export const PLUGIN_NAME = "Come Through";
export const Icon = {
PLUGIN: "drill",
CARD_FRONT: "file-output",
CARD_BACK: "file-input",
} as const;

View file

@ -0,0 +1,67 @@
import { TableSectionCreator } from "utils/dom/table";
import { CreateElParam, createElWrapper, createWrappedEl } from "utils/obs/dom";
export type ElementCreatorOptions<K extends keyof HTMLElementTagNameMap> = Omit<CreateElParam<K>, "tag" | "parent"> & {
parent?: HTMLElement
};
/**
* Convenience facade to reduce code in views.
*/
export class ElementCreator {
private readonly defaultParent: HTMLDivElement;
constructor(defaultParent: HTMLDivElement) {
this.defaultParent = defaultParent;
}
public appendChild<T extends HTMLElement>(el: T): T {
return this.defaultParent.appendChild(el);
}
public para(o?: DomElementInfo | string, parent?: HTMLElement) {
return createWrappedEl({
parent: parent ?? this.defaultParent,
tag: "p",
o: o,
});
}
public paraWrapper(parent?: HTMLElement) {
return createElWrapper(parent ?? this.defaultParent, "p");
}
public table(builder?: (section: TableSectionCreator) => void, options?: ElementCreatorOptions<"table">) {
const table = createWrappedEl(this.toCreateParam("table", options));
if (builder !== undefined)
builder(new TableSectionCreator(table));
return table;
}
public el<K extends keyof HTMLElementTagNameMap>(
tag: K,
o?: DomElementInfo | string,
createdCallback?: (el: HTMLElementTagNameMap[K]) => void,
parent?: HTMLElement): HTMLElementTagNameMap[K] {
return createWrappedEl({
tag: tag,
parent: parent ?? this.defaultParent,
o: o,
createdCallback: createdCallback,
});
}
/** Converts {@link ElementCreatorOptions} to {@link CreateElParam}. */
private toCreateParam<K extends keyof HTMLElementTagNameMap>(
tag: K,
info?: ElementCreatorOptions<K>
): CreateElParam<K> {
return info === undefined
? { tag: tag, parent: this.defaultParent }
: {
...info,
tag: tag,
parent: info.parent ?? this.defaultParent,
};
}
}

64
src/utils/datetime.ts Normal file
View file

@ -0,0 +1,64 @@
import { Env } from "env";
// Make sure you're doing import { moment} from 'obsidian' so that you don't import another copy — https://docs.obsidian.md/oo24/plugin#Performance
import { moment } from "obsidian"; // TODO: Use Luxon
import { Str } from "utils/ts";
// @ts-expect-error
import { DateTime as LuxonDateTime } from "luxon";
const TIME_FORMAT = "LT";
const DATE_FORMAT = "MMM D, LT";
export const DateTime = {
toTimeString: (date: Date) => moment(date).format(TIME_FORMAT),
toString: (date: Date) => moment(date).format(DATE_FORMAT),
dateStringFromIso: (iso8601: string): string => {
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
return moment(iso8601).format(DATE_FORMAT);
},
dateFromIso: (iso8601: string): Date => {
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
return moment(iso8601).toDate();
},
toIso: (date: Date) => date.toISOString(),
/**
* @param date
* @param compareDate
* @returns `true` if {@link compareDate} is later than {@link date}.
*/
isDateLater: (date: Date, compareDate: Date): boolean => compareDate.getTime() - date.getTime() > 0 ? true : false,
diffString: (now: Date, otherDate: Date): string => {
const dtNow = LuxonDateTime.fromJSDate(now);
const dtOther = LuxonDateTime.fromJSDate(otherDate);
const options = {
// [toHuman](https://moment.github.io/luxon/api-docs/index.html#durationtohuman)
showZeros: false, // Show all units previously used by the duration even if they are zero
listStyle: "long",
// [locale options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#locale_options)
// [style options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#style_options)
unitDisplay: "long",
// [digit options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#digit_options)
minimumIntegerDigits: 1,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
//minimumSignificantDigits: 1,
//maximumSignificantDigits: 21,
// [other options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#other_options)
signDisplay: "never",
};
const diff = dtOther.diff(dtNow);
const humanizedString = diff.shiftTo("years", "months", "days").toHuman(options);
return diff.as("milliseconds") < 0 ? `${humanizedString} ago` : `In ${humanizedString}`;
},
} as const;

View file

@ -1,48 +1,5 @@
export const CssClass = {
Plugin: {
WORKSPACE_LEAF_CONTENT: "come-through-workspace-leaf-content"
},
MarkdownView: {
READING: () => "markdown-reading-view",
PREVIEW: () => [
"markdown-preview-view",
"markdown-rendered",
"node-insert-event",
"is-readable-line-width",
"allow-fold-headings",
"allow-fold-lists",
"show-indentation-guide",
"show-properties",
],
SIZER: () => [
"markdown-preview-sizer",
"markdown-preview-section",
],
PUSHER: () => [
"markdown-preview-pusher"
],
MOD: () => [
"mod-header",
"mod-ui"
],
},
Embed: {
INTERNAL: "internal-embed",
MEDIA: "media-embed",
AUDIO: "audio-embed",
},
State: {
LOADED: "is-loaded",
ERROR: "is-error",
}
export const HtmlTag = {
SPAN: "span",
} as const;
export const HtmlAttribute = {
@ -53,6 +10,13 @@ export const HtmlAttribute = {
}
},
Lang: {
NAME: "lang",
Values: {
THAI: "th"
}
},
MediaElement: {
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#controls
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/controls

66
src/utils/dom/table.ts Normal file
View file

@ -0,0 +1,66 @@
export class TableSectionCreator {
public readonly tableEl: HTMLTableElement;
constructor(tableEl: HTMLTableElement) {
this.tableEl = tableEl;
}
public setHeader(cb: (rowCreator: TableRowCreator) => void) {
return this.create("thead", cb);
}
/** It is possible to have multiple <tbody> elements in the same table. */
public addBody(cb: (rowCreator: TableRowCreator) => void) {
return this.create("tbody", cb);
}
public setFooter(cb: (rowCreator: TableRowCreator) => void) {
return this.create("tfoot", cb);
}
private create(tag: "thead" | "tbody" | "tfoot", cb: (rowCreator: TableRowCreator) => void) {
const r = new TableRowCreator(this.tableEl.createEl(tag), tag === "thead");
cb(r);
return r;
}
}
export class TableRowCreator {
public readonly sectionEl: HTMLTableSectionElement;
/** Whether this row is a header row. */
public readonly isHeader: boolean;
constructor(sectionEl: HTMLTableSectionElement, isHeader = false) {
this.sectionEl = sectionEl;
this.isHeader = isHeader;
}
public add(cb?: (colCreator: TableColCreator) => void) {
return this.create(this.isHeader ? "th" : "td", cb);
}
private create(tag: "th" | "td", cb?: (colCreator: TableColCreator) => void): TableColCreator {
const colCreator = new TableColCreator(this.sectionEl.createEl("tr"), tag);
cb?.(colCreator);
return colCreator;
}
}
export class TableColCreator {
public readonly rowEl: HTMLTableRowElement;
public readonly tag: "th" | "td";
constructor(rowEl: HTMLTableRowElement, tag: "th" | "td") {
this.rowEl = rowEl;
this.tag = tag;
}
public add(o?: DomElementInfo | string, cb?: (cellEl: HTMLTableCellElement) => void) {
return this.rowEl.createEl(this.tag, o, cb);
}
/** If this instance creates table header cells (`th`), calling this is equal to calling {@link add}. */
public addHeader(o?: DomElementInfo | string, cb?: (cellEl: HTMLTableCellElement) => void) {
return this.rowEl.createEl("th", o, cb);
}
};

18
src/utils/errors.ts Normal file
View file

@ -0,0 +1,18 @@
export class TimeoutError extends Error {
constructor(message: string = 'Operation timed out') {
super(message);
this.name = 'TimeoutError';
}
}
/**
* Used to indicate that a value was unexpectedly `undefined`.
* This is a self-documenting and code clarity measure, especially in places where the `!` non-null assertion operator could otherwise be used.
*/
export class UnexpectedUndefinedError extends Error {
constructor(message?: string) {
super(message ?? "Unexpected undefined value");
this.name = "UnexpectedUndefinedError";
}
}

View file

@ -66,7 +66,7 @@ interface ExternalSectionCache extends SectionCache {
externalType: "frontmatter" //| "backmatter"
}
/** Provides a set of common helpers for interpreting the structure and metadata of markdown files. */
export abstract class FileParser {
protected static readonly SECTION_TYPE_HEADING = "heading";

View file

@ -1,7 +1,8 @@
import { Env } from "env";
import { ItemView } from "obsidian";
import { CssClass } from "renderings/dom-constants";
import { MarkupAssistant } from "renderings/MarkupAssistant";
import { UnexpectedUndefinedError } from "utils/errors";
import { CssClass } from "utils/obs/constants";
import { Arr } from "utils/ts";
export class ViewAssistant {
@ -12,13 +13,11 @@ export class ViewAssistant {
public init(view: ItemView) {
this.deinit();
view.containerEl.addClass(CssClass.Plugin.WORKSPACE_LEAF_CONTENT);
this.markdownViewRootEl = view.contentEl.createDiv({ cls: CssClass.MarkdownView.READING() }, (readerViewEl) => {
this.previewView = readerViewEl.createDiv({ cls: CssClass.MarkdownView.PREVIEW() }, (previewViewEl) => {
this.previewSizerEl = previewViewEl.createDiv({ cls: CssClass.MarkdownView.SIZER() }, (el) => {
el.createDiv({ cls: CssClass.MarkdownView.PUSHER() });
el.createDiv({ cls: CssClass.MarkdownView.MOD() });
this.markdownViewRootEl = view.contentEl.createDiv({ cls: CssClass.MarkdownView.READING }, (readerViewEl) => {
this.previewView = readerViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.PREVIEW) }, (previewViewEl) => {
this.previewSizerEl = previewViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.SIZER) }, (el) => {
el.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.PUSHER) });
el.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.MOD) });
});
});
});
@ -36,14 +35,26 @@ export class ViewAssistant {
public empty() {
if (this.previewSizerEl) {
const children = this.previewSizerEl.children;
for (let i = children.length - 1; i >= 2; i--)
this.previewSizerEl.removeChild(children[i]);
for (let i = children.length - 1; i >= 2; i--) {
const child = children[i];
if (child === undefined)
throw new UnexpectedUndefinedError();
this.previewSizerEl.removeChild(child);
}
}
}
/** @throws `Error` if {@link init} has not been called. */
public get containerEl() {
if (this.markdownViewRootEl === undefined)
throw new Error("Not initialized.");
return this.markdownViewRootEl;
}
/** @throws `Error` if {@link init} has not been called. */
public get contentEl() {
if (!this.previewSizerEl)
throw new Error();
if (this.previewSizerEl === undefined)
throw new Error("Not initialized.");
return this.previewSizerEl;
}
@ -67,25 +78,4 @@ export class ViewAssistant {
Env.log.view(`ViewAssistant:adjustAvailableVerticalScrolling: \`padding-bottom\`: "${paddingBottom}px", \`min-height\`: "${minHeight}px"`);
}
public createEl<K extends keyof HTMLElementTagNameMap>(tag: K, o?: DomElementInfo | string, callback?: (el: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
return MarkupAssistant.createWrappedEl(this.contentEl, tag, o, callback);
}
public createPara(o?: DomElementInfo | string) {
return MarkupAssistant.createWrappedPara(this.contentEl, o);
}
public createParaWrapper() {
return MarkupAssistant.createElWrapper(this.contentEl, "p");
}
public createTable() {
const container = this.contentEl.createDiv({
cls: MarkupAssistant.classForEl("table"),
attr: {
//"dir": "ltr"
}
});
return container.createEl("table");
}
}

View file

@ -0,0 +1,74 @@
export const CssClass = {
wrapperClassForEl: <K extends keyof HTMLElementTagNameMap>(tag: K) => {
return "el-" + tag;
},
MarkdownView: {
READING: "markdown-reading-view",
/**
* The container that styles HTML rendered markdown (e.g, with `MarkdownRenderer`) the
* standard way it looks in Obsidian's reader view.
*
* `<div class="markdown-preview-view">`
*
* Some additional classes, of which the inclusion of most depends on the user's appearance settings:
*
* - markdown-rendered: Applied to indicate the content has been processed and rendered from markdown source. This class marks that the markdown has been fully parsed and converted to HTML DOM elements.
* - node-insert-event: Used to signal that DOM mutation events should be handled for dynamically inserted nodes. This enables proper event handling when new content is added to the rendered markdown.
* - is-readable-line-width: Applied when the user has enabled the "Readable line length" setting in Editor preferences. This constrains the maximum width of text lines (typically to ~700px) for improved readability.
* - allow-fold-headings: Applied when the user has enabled heading folding in Editor settings. This allows users to collapse/expand content under headings by clicking fold indicators in the gutter.
* - allow-fold-lists: Applied when the user has enabled list folding in Editor settings. This allows users to collapse/expand nested list items by clicking fold indicators.
* - show-indentation-guide: Applied when the user has enabled "Show indentation guides" in Editor settings. This displays vertical lines to visualize indentation levels in lists and nested content.
* - show-properties: Applied when the user has enabled property/frontmatter visibility. This controls whether YAML frontmatter and metadata properties are displayed in the rendered view.
*/
PREVIEW: [
"markdown-preview-view",
"markdown-rendered",
"node-insert-event",
"is-readable-line-width",
"allow-fold-headings",
"allow-fold-lists",
"show-indentation-guide",
"show-properties",
],
SIZER: [
"markdown-preview-sizer",
"markdown-preview-section",
],
PUSHER: [
"markdown-preview-pusher"
],
MOD: [
"mod-header",
"mod-ui"
],
},
Modal: {
BUTTON_CONTAINER: "modal-button-container",
LG: "mod-lg",
CANCEL: "mod-cancel",
},
Setting: {
Item: {
DESC: "setting-item-description",
}
},
Embed: {
INTERNAL: "internal-embed",
MEDIA: "media-embed",
AUDIO: "audio-embed",
},
State: {
LOADED: "is-loaded",
ERROR: "is-error",
}
} as const;

100
src/utils/obs/dom.ts Normal file
View file

@ -0,0 +1,100 @@
import { Arr, Str } from "utils/ts";
import { CssClass } from "utils/obs/constants";
export type CreateElParam<K extends keyof HTMLElementTagNameMap> = {
/** The tag name of the element to be created. */
tag: K,
/** Applies to the wrapped element. */
o?: DomElementInfo | string,
/** The wrappers parent. */
parent: HTMLElement,
/** Wrapper classes to be added to the wrapper element. */
wrapperClasses?: string[],
/** If `true`, the default class for wrappers will not be added to the wrapper's class list. */
skipWrapperClass?: boolean,
/** Callback to be executed after the element is created. */
createdCallback?: (el: HTMLElementTagNameMap[K]) => void,
};
export function createWrappedEl<K extends keyof HTMLElementTagNameMap>(options: CreateElParam<K>): HTMLElementTagNameMap[K] {
const {
tag,
o,
parent,
wrapperClasses,
skipWrapperClass = false,
createdCallback,
} = options;
const opt: DomElementInfo | undefined = Arr.nonEmpty(wrapperClasses) ? { cls: wrapperClasses } : undefined;
const wrappedEl = skipWrapperClass ? parent.createDiv(opt) : createElWrapper(
parent,
tag,
opt,
);
return wrappedEl.createEl(tag, o, createdCallback);
}
/**
* Mimics how Obsidian/CodeMirror adds wrapper elements. For example, a `<p>` is wrapped with `<div class="el-p">`.
*
* @param appendPara Sometimes a `<p>` is added as an additional child; for example, if the second starting tag after the first is on the same line as the first. This param serves as documentation of this behavior, which is considered abnormal.
* @returns A created wrapper element for the given {@link tag} (the actual wrapped element needs to be created separately).
*/
export function createElWrapper<K extends keyof HTMLElementTagNameMap>(parent: HTMLElement, tag: K, o?: DomElementInfo, appendPara = false) {
const clsToAdd = CssClass.wrapperClassForEl(appendPara ? "p" : tag);
const finalOptions: DomElementInfo = o ? { ...o } : {};
if (Str.nonEmpty(finalOptions.cls))
finalOptions.cls = `${clsToAdd} ${finalOptions.cls}`;
else if (Arr.nonEmpty(finalOptions.cls))
finalOptions.cls = [clsToAdd, ...finalOptions.cls];
else
finalOptions.cls = clsToAdd;
if (appendPara) {
// If the second starting tag after the first is on the same line as the first (e.g., <audio ..></audio> or <audio ..><source ..>\n</audio>),
// it's wrapped `<div class="el-p"><p dir="auto">`;
// otherwise `<div class="el-tagname">`.
//
// Nevertheless, it's always wrapped in a div.
// - audio, video, blockquote, p, pre, details, ...
return parent.createDiv(finalOptions).createEl("p", { attr: { dir: "auto" } });
} else {
return parent.createDiv(finalOptions);
}
}
/**
* This is the string-based version of {@link createElWrapper}. It mimics how
* Obsidian/CodeMirror wraps elements in a classed `<div>`. For example, an
* `<audio>` tag string would be returned wrapped in a `<div class="el-audio">`.
*
* @param tag The tag name at the root of {@link html}.
* @param html The HTML string to wrap.
* @param appendPara See {@link createElWrapper}.
* @returns The wrapped HTML string.
*/
export function createElWrapperHtml<K extends keyof HTMLElementTagNameMap>(tag: K, html: string, appendPara = false) {
if (appendPara)
return `<div class="${CssClass.wrapperClassForEl("p")}"><p>${html}</p></div>`;
else
return `<div class="${CssClass.wrapperClassForEl(tag)}">${html}</div>`;
}
/**
* Use `element.win` and `element.doc` to get the window/document that your Dom element is located in.
*
* `activeWindow`/`Document` refers to the current focused window which might not be the same one your element is in.
*/
export function getDoc(el: HTMLElement) {
return el.doc;
}
/** @see {@link getDoc} */
export function getWin(el: HTMLElement) {
return el.win;
}

44
src/utils/obs/internal.ts Normal file
View file

@ -0,0 +1,44 @@
import { Env } from "env";
import { App, KeymapEventListener, Scope } from "obsidian";
export const InternalApi = {
/**
* Add {@link listener} to the first currently configured keymap identified by {@link id}.
* @returns `false` if no keymap for {@link id} was found.
*/
addEventHandlerToExistingKeyMap: (app: App, scope: Scope, id: string, listener: KeymapEventListener) => {
try {
// @ts-expect-error
const manager = app.hotkeyManager;
Env.dev?.assert(manager);
const keys: {
// @ts-expect-error
modifiers: Modifier[];
key: string;
}[] = manager.customKeys[id] ?? manager.defaultKeys[id];
if (keys.length === 0 || keys[0] === undefined)
return false;
scope.register(keys[0].modifiers, keys[0].key, listener);
return true;
}
catch (e) {
Env.log.e("Failed to add event handler to existing key map.", e);
return false;
}
},
reloadApp: (app: App) => {
try {
// @ts-expect-error
app.commands.executeCommandById("app:reload");
}
catch (e) {
Env.log.e("Failed to execute reload command.", e);
}
},
} as const;

109
src/utils/ts.ts Normal file
View file

@ -0,0 +1,109 @@
import { Env } from "env";
import { UnsignedInteger } from "types";
import { TimeoutError } from "utils/errors";
export const Arr = {
firstOrNull: <T>(a: Array<T>): T | null => a.first() ?? null,
nonEmpty: <T>(v: T[] | unknown): v is Array<T> => Array.isArray(v) && v.length > 0,
toMutable: <T>(a: readonly T[]): T[] => [...a],
} as const;
export const Async = {
/**
* @param promise The promise to execute
* @param timeoutMs The timeout in milliseconds
* @returns A new promise that will resolve with the original promise's result or reject with {@link Err.TimeoutError}
* @throws Throws a {@link Err.TimeoutError} if the promise does not settle within the specified timeout
*
* @example
* ```typescript
* try {
* await withTimeout(slowOperation(), 1000);
* } catch (error) {
* if (error instanceof Err.TimeoutError) {
* console.log('Operation timed out');
* }
* }
* ```
*/
withTimeout: <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
// Create a promise that rejects in <timeoutMs> milliseconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new TimeoutError(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
// Race the input promise against the timeout promise
return Promise.race([
promise,
timeoutPromise
]);
}
}
export const Err = {
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
} as const;
export const KeyValue = {
isNotEmpty: <K, V>(v: Map<K, V> | undefined): v is Map<K, V> => v !== undefined && v.size > 0,
} as const;
export const Num = {
is: (value: unknown): value is number => {
return typeof value === "number";
},
UInt: {
assert: (n: number) => Env.assert(isUnsignedInteger(n)),
create: (n: number): UnsignedInteger => {
if (!isUnsignedInteger(n))
throw new Error('Invalid UnsignedNumber: must be a non-negative integer.');
return n;
},
is: isUnsignedInteger,
} as const,
} as const;
export const Obj = {
/**
* If {@link value} is `null`, `false` is returned even thoigh `null` is an object.
*
* @param value
* @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`.
*/
is: (value: unknown): value is object => {
return typeof value === "object" && value !== null; // `null` is an object
},
}
export const Str = {
EMPTY: "",
SPACE: " ",
NON_BREAKING_SPACE: " ",
is: (value: unknown): value is string => typeof value === "string",
isNonEmpty: (value: unknown): value is string => typeof value === "string" && value !== "",
nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined,
/**
* 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`.
*/
toIsoStringOrNull: (date: Date | undefined) => {
return date !== undefined ? date.toISOString() : null
},
} as const;
function isUnsignedInteger(value: unknown): value is UnsignedInteger {
return Num.is(value) && Number.isInteger(value) && value >= 0;
}

View file

@ -1,10 +1,13 @@
import { DataStore, DataStoreRoot } from "DataStore";
import { DataStore, DataStoreRoot } from "data/DataStore";
import { Env } from "env";
import { ItemView, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { IconName, ItemView, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ContentRenderer, createRenderConfig } from "renderings/content/ContentRenderer";
import { PluginSettings, SettingsChanged, SettingsManager } from "Settings";
import { PLUGIN_ICON } from "UIAssistant";
import { ViewAssistant } from "./ViewAssistant";
import { Icon } from "ui/constants";
import { ElementCreator } from "utils/ElementCreator";
import { getDoc } from "utils/obs/dom";
import { ViewAssistant } from "utils/obs/ViewAssistant";
import { CssClass } from "views/constants";
export type BaseViewOptionalParameters = {
data?: DataStore;
@ -14,81 +17,128 @@ export interface BaseViewState {
[key: string]: unknown;
}
export interface BaseViewEphemeralState {
[key: string]: unknown;
}
export abstract class BaseView<State extends BaseViewState> extends ItemView {
protected readonly settingsManager: SettingsManager;
private readonly options?: BaseViewOptionalParameters;
protected readonly contentRenderer: ContentRenderer;
protected readonly viewAssistant = new ViewAssistant();
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, options?: BaseViewOptionalParameters) {
private readonly options?: BaseViewOptionalParameters;
private readonly viewAssistant: ViewAssistant;
private domFacade: BaseViewDomFacade | null = null;
public constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, options?: BaseViewOptionalParameters) {
Env.log.d("BaseView:constructor");
super(leaf);
this.settingsManager = settingsManager;
this.options = options;
this.viewAssistant = new ViewAssistant();
this.contentRenderer = new ContentRenderer(this.app, createRenderConfig(settingsManager.settings));
this.addChild(this.contentRenderer);
}
public override onload(): void {
Env.log.view("onload");
Env.log.d("BaseView:onload");
super.onload();
}
public override onunload(): void {
Env.log.view("onunload");
Env.log.d("BaseView:onunload");
super.onunload();
}
public override getIcon() {
Env.log.view("getIcon");
return PLUGIN_ICON;
public override getIcon(): IconName {
Env.log.d("BaseView:getIcon");
return Icon.PLUGIN;
}
protected override async onOpen(): Promise<void> {
Env.log.view("onOpen");
this.settingsManager?.registerOnChangedCallback(this.settingsChangedCallback);
Env.log.d("BaseView:onOpen");
await super.onOpen();
this.settingsManager.registerOnChangedCallback(this.settingsChangedCallback);
if (this.options) {
this.options.data?.registerOnChangedCallback(this.dataChangedCallback);
}
this.contentEl.empty();
this.containerEl.addClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
this.viewAssistant.init(this);
}
protected override async onClose(): Promise<void> {
Env.log.view("onClose");
this.settingsManager?.unregisterOnChangedCallback(this.settingsChangedCallback);
Env.log.d("BaseView:onClose");
await super.onClose();
this.settingsManager.unregisterOnChangedCallback(this.settingsChangedCallback);
if (this.options) {
this.options.data?.unregisterOnChangedCallback(this.dataChangedCallback);
}
this.contentRenderer.unload(); // Will also be unloaded when this view unloads.
this.domFacade = null;
this.viewAssistant.deinit();
this.containerEl.removeClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
}
public override onResize(): void {
Env.log.view("onResize");
Env.log.d("BaseView:onResize");
super.onResize();
this.viewAssistant.adjustAvailableVerticalScrolling();
}
public override async setState(state: State, result: ViewStateResult) {
Env.log.view("setState");
this.onSetState(state, result);
await this.refreshView();
/** Defines the fundamental configuration needed to recreate the view's essential content and layout. */
public override async setState(state: unknown, result: ViewStateResult): Promise<void> {
Env.log.d("BaseView:setState:", this.didSetState, state);
await super.setState(state, result);
if (!this.didSetState) {
this.onSetState(state as State, result);
this.didSetState = true;
//this.contentRenderer.recycle();
Env.log.view("BaseView:setState: refreshing view because state was set");
await this.render();
}
}
/** Subclasses should store and manage their own {@link BaseViewState}. {@link onSetState} is only called once per instantiation of this class. */
private didSetState: boolean = false;
public override getState(): Record<string, unknown> {
Env.log.view("getState");
return this.onGetState();
Env.log.d("BaseView:getState");
return {
...super.getState(),
...this.onGetState(),
};
}
public override setEphemeralState(state: unknown): void {
Env.log.d("BaseView:setEphemeralState", this.didSetEphemeralState, state);
super.setEphemeralState(state);
if (!this.didSetEphemeralState) {
this.onSetEphemeralState(state);
this.didSetEphemeralState = true;
}
}
/** Subclasses should store and manage their own {@link BaseViewEphemeralState}. {@link onSetEphemeralState} is only called once per instantiation of this class. */
private didSetEphemeralState: boolean = false;
public override getEphemeralState(): Record<string, unknown> {
Env.log.d("BaseView:getEphemeralState");
return {
... super.getEphemeralState(),
... this.onGetEphemeralState()
};
}
private dataChangedCallback = async (data: DataStoreRoot) => {
Env.log.view("dataChangedCallback");
Env.log.d("BaseView:dataChangedCallback");
const outParams = {
skipRender: false,
};
@ -96,11 +146,11 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
this.onDataChanged(data, outParams);
if (!outParams.skipRender)
await this.refreshView();
await this.render();
}
private settingsChangedCallback: SettingsChanged = async (settings, isExternal) => {
Env.log.view("settingsChangedCallback");
Env.log.d("BaseView:settingsChangedCallback");
if (!isExternal)
this.contentRenderer.config = createRenderConfig(settings);
@ -111,11 +161,16 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
this.onSettingsChanged(settings, isExternal, outParams);
if (!outParams.skipRender)
await this.refreshView();
await this.render();
}
protected refreshView = async () => {
Env.log.view("refreshView");
/**
* Recycles/clears/resets everything and invokes {@link onRender}.
*
* Subclasses should call this method to build or rebuild the view.
*/
protected render = async () => {
Env.log.d("BaseView:render");
this.contentRenderer.recycle();
this.viewAssistant.empty();
@ -123,24 +178,52 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
this.viewAssistant.adjustAvailableVerticalScrolling();
};
/** Invoked when the system supplies the state. Save it if needed. */
protected abstract onSetState(state: State, result: ViewStateResult): void;
/** Supply the state to the system. */
protected abstract onGetState(): State;
protected onSetEphemeralState(state: unknown): void { };
protected onGetEphemeralState(): Record<string, unknown> { return {}; };
protected abstract onRender(): Promise<void>;
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void {};
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void {};
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void { };
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void { };
protected get scrollPosition(): { top: number, left: number } {
return this.viewAssistant.scrollContainer
? { top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft }
: { top: 0, left: 0};
: { top: 0, left: 0 };
}
protected setScrollPosition(options: ScrollToOptions) {
Env.log.d("BaseView:setScrollPosition", options);
this.viewAssistant.scrollContainer?.scrollTo(options)
}
protected saveState() {
Env.log.d("BaseView:saveState");
this.app.workspace.requestSaveLayout();
}
/** Subclasses should use this to access the DOM. */
protected get dom(): BaseViewDomFacade {
if (this.domFacade === null) {
const contentEl = this.viewAssistant.contentEl;
this.domFacade = {
contentEl: contentEl,
create: new ElementCreator(contentEl),
doc: getDoc(contentEl),
}
}
return this.domFacade;
}
}
type BaseViewDomFacade = {
/** The root HTML element for the view's content. */
contentEl: HTMLElement,
/** Use to create new DOM elements within the view's content element. */
create: ElementCreator,
/** The document associated with the view's content element. */
doc: Document,
};

View file

@ -1,10 +1,9 @@
import { DataStore } from "DataStore";
import { DataStore } from "data/DataStore";
import { DeckModal } from "modals/DeckModal";
import { Menu, setIcon, setTooltip, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { IconName, Menu, setIcon, setTooltip, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { SettingsManager } from "Settings";
import { BaseView, BaseViewState } from "views/BaseView";
export class DecksView extends BaseView<BaseViewState> {
public static readonly TYPE = "come-through-view-decks";
@ -18,26 +17,26 @@ export class DecksView extends BaseView<BaseViewState> {
this.navigation = true;
}
public override getIcon() {
public override getIcon(): IconName {
return "file-stack";
}
public getViewType(): string {
public override getViewType(): string {
return DecksView.TYPE;
}
public getDisplayText(): string {
public override getDisplayText(): string {
return "Decks";
}
protected onSetState(state: BaseViewState, result: ViewStateResult): void {
protected override onSetState(state: BaseViewState, result: ViewStateResult): void {
}
protected onGetState(): BaseViewState {
protected override onGetState(): BaseViewState {
return {};
}
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
super.onPaneMenu(menu, source);
if (source === "tab-header")
return;
@ -46,86 +45,87 @@ export class DecksView extends BaseView<BaseViewState> {
item.setTitle("Reload");
item.setSection("pane");
item.setIcon("refresh-cw");
item.onClick(this.refreshView);
item.onClick(this.render);
});
}
protected async onRender() {
protected override async onRender(): Promise<void> {
const allCards = this.data.getAllCards();
const decks = this.data.getAllDecks();
const numberOfCardsInDefaultDeck = allCards.filter(this.data.filter.cardsWithoutDeck).length;
//this.viewAssistant.createEl("h1", { text: "Decks" });
//this.dom.create.el("h1", { text: "Decks" });
this.viewAssistant.createPara({
this.dom.create.para({
text: `There are ${allCards.length - numberOfCardsInDefaultDeck} cards in a total of ${decks.length} decks.`
});
if (numberOfCardsInDefaultDeck > 0) {
this.viewAssistant.createPara({
this.dom.create.para({
text: `${(numberOfCardsInDefaultDeck == 1 ? "1 card is" : `${numberOfCardsInDefaultDeck} cards are`)} not assigned to any deck.`
});
}
const table = this.viewAssistant.createTable()
const header = table.createEl("thead");
header.createEl("tr", {}, (headerRow) => {
headerRow.createEl("th", { text: "Name" });
headerRow.createEl("th", { text: "Cards" });
headerRow.createEl("th", { text: "Parent deck" });
headerRow.createEl("th", { text: "" }, (th) => {
th.createEl("button", { text: "Add" }, (button) => {
setIcon(button, "plus");
setTooltip(button, "Add new deck");
this.contentRenderer.registerDomEvent(button, "click", () => new DeckModal(this.app, this.data, () => { }).open());
});
});
});
this.dom.create.table((section) => {
const body = table.createEl("tbody");
for (const deck of decks) {
const numberOfCardsInDeck = allCards.filter(card => this.data.filter.cardsInDeck(deck.id, card)).length;
const rowID = body.createEl("tr");
rowID.createEl("td", { text: deck.data.n });
rowID.createEl("td", {
text: numberOfCardsInDeck.toString()
});
rowID.createEl("td", {
text: deck.data.p.length == 0 ? "None" : deck.data.p
.map(parentID => this.data.getDeck(parentID))
.filter(d => d !== null)
.map(d => d.n)
.join(", ")
});
rowID.createEl("td", {}, (td) => {
const ellipsisButton = td.createEl("button", {}, (button) => {
this.contentRenderer.registerDomEvent(button, 'click', (evt: MouseEvent) => {
const menu = new Menu();
menu.addItem((item) => {
item.setTitle("Edit");
item.setIcon("pen");
item.onClick(() => new DeckModal(this.app, this.data, () => { }, deck.id).open());
section.setHeader((row) => {
row.add((col) => {
col.add({ text: "Name" });
col.add({ text: "Cards" });
col.add({ text: "Parent deck" });
col.add(undefined, (el) => {
el.createEl("button", { text: "Add" }, (button) => {
setIcon(button, "plus");
setTooltip(button, "Add new deck");
this.contentRenderer.registerDomEvent(button, "click", () => new DeckModal(this.app, this.data, () => { }).open());
});
menu.addItem((item) => {
item.setTitle("Delete")
item.setIcon("trash")
item.setDisabled(numberOfCardsInDeck > 0)
item.onClick(async () => {
this.data.deleteDeck(deck.id, undefined, true);
await this.data.save();
});
});
menu.showAtMouseEvent(evt);
});
});
setIcon(ellipsisButton, "ellipsis-vertical");
});
}
section.addBody((row) => {
for (const deck of decks) {
const numberOfCardsInDeck = allCards.filter(card => this.data.filter.cardsInDeck(deck.id, card)).length;
row.add((col) => {
col.add({ text: deck.data.n });
col.add({ text: numberOfCardsInDeck.toString() });
col.add({
text: deck.data.p.length == 0 ? "None" : deck.data.p
.map(parentID => this.data.getDeck(parentID))
.filter(d => d !== null)
.map(d => d.n)
.join(", ")
});
col.add(undefined, (el) => {
const ellipsisButton = el.createEl("button", undefined, (button) => {
this.contentRenderer.registerDomEvent(button, 'click', (evt: MouseEvent) => {
const menu = new Menu();
menu.addItem((item) => {
item.setTitle("Edit");
item.setIcon("pen");
item.onClick(() => new DeckModal(this.app, this.data, () => { }, deck.id).open());
});
menu.addItem((item) => {
item.setTitle("Delete")
item.setIcon("trash")
item.setDisabled(numberOfCardsInDeck > 0)
item.onClick(async () => {
this.data.deleteDeck(deck.id, undefined, true);
await this.data.save();
});
});
menu.showAtMouseEvent(evt);
});
});
setIcon(ellipsisButton, "ellipsis-vertical");
});
});
}
});
});
}
}

View file

@ -1,5 +1,5 @@
import { ContentParser } from "ContentParser";
import { DataStore } from "DataStore";
import { DataStore } from "data/DataStore";
import t from "Localization";
import { Menu, Scope, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { HeadingProcessor } from "renderings/content/HeadingProcessor";
@ -38,14 +38,14 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
this.navigation = true;
this.scope = new Scope(this.app.scope);
this.scope.register(["Mod"], "R", this.refreshView);
this.scope.register(["Mod"], "R", this.render);
}
public getViewType() {
public override getViewType(): string {
return DefinedContentView.TYPE;
}
public getDisplayText() {
public override getDisplayText(): string {
return t.views.declarations.title(this.file ?? undefined);
}
@ -63,7 +63,7 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
}
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
super.onPaneMenu(menu, source);
if (source === "tab-header")
return;
@ -72,22 +72,22 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
item.setTitle("Reload");
item.setSection("pane");
item.setIcon("refresh-cw");
item.onClick(this.refreshView);
item.onClick(this.render);
});
}
protected onSetState(state: DefinedContentViewState, result: ViewStateResult): void {
protected override onSetState(state: DefinedContentViewState, result: ViewStateResult): void {
this.file = state.filePath ? this.app.vault.getFileByPath(state.filePath) : undefined;
this.contentRenderer.file = this.file;
}
protected onGetState(): DefinedContentViewState {
protected override onGetState(): DefinedContentViewState {
return {
filePath: this.file?.path,
}
}
protected override onDataChanged() {
protected override onDataChanged(): void {
// On file rename, this will be called superflously.
// But since this case is a rare and the solution to avoid the extra render call is too ugly, because of lack of obsidian APIs, it's not worth it implementing.
// So let it call render two times.
@ -110,10 +110,10 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
}
};
protected async onRender() {
protected override async onRender(): Promise<void> {
if (!this.file) {
console.error("No file set.")
this.viewAssistant.createPara({ text: t.views.declarations.fileNotSet });
this.dom.create.para({ text: t.views.declarations.fileNotSet });
return;
}
@ -121,8 +121,8 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
this.contentRenderer.setCustomProcessors([new HeadingProcessor(3)]);
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
this.viewAssistant.createEl("h1", { text: t.views.declarations.title(this.file) });
const infoPara = this.viewAssistant.createParaWrapper();
this.dom.create.el("h1", { text: t.views.declarations.title(this.file) });
const infoPara = this.dom.create.paraWrapper();
const parsedContent = await ContentParser.getCardFromFile(this.file, this.app, {
contentRead: {
@ -130,8 +130,8 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
}
});
var numberOfContentDefinitions = 0;
var numberOfIncompleteContentDefinitionsInFile = 0;
let numberOfContentDefinitions = 0;
let numberOfIncompleteContentDefinitionsInFile = 0;
for (const parsedUnit of Object.values(parsedContent)) {
@ -155,7 +155,7 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
}
}
var markdownText = this.app.fileManager.generateMarkdownLink(this.file, "") + // custom view has not sourcePath.
const markdownText = this.app.fileManager.generateMarkdownLink(this.file, "") + // custom view has not sourcePath.
` contains declarations that defines ${numberOfContentDefinitions + numberOfIncompleteContentDefinitionsInFile} content blocks` +
`${numberOfIncompleteContentDefinitionsInFile > 0 ? " (" + numberOfIncompleteContentDefinitionsInFile + " incomplete)" : ""}` +
`, amounting to ${numberOfContentDefinitions / 2} review units.`;
@ -164,7 +164,7 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
}
private createDefinedContentBlockSection(summary: string, content?: string) {
this.viewAssistant.createEl("details", undefined, (detailsEl) => {
this.dom.create.el("details", undefined, (detailsEl) => {
detailsEl.createEl("summary", { text: summary });
const contentDiv = detailsEl.createDiv();
this.contentRenderer.registerDomEvent(detailsEl, "toggle", async () => {

View file

@ -1,459 +0,0 @@
import { ContentParser, ParsedCard } from "ContentParser";
import { DataStore, DeckIDDataTuple, StatisticsData } from "DataStore";
import { Env } from "env";
import { FileParserError } from "FileParser";
import { DeckID, FullID } from "FullID";
import { InternalApi } from "InternalApi";
import { Keymap, KeymapEventListener, Menu, Scope, setIcon, setTooltip, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { HeadingProcessor } from "renderings/content/HeadingProcessor";
import { Scheduler } from "Scheduler";
import { SettingsManager } from "Settings";
import { Grade, IPreview, Rating, show_diff_message, State, TypeConvert } from "ts-fsrs";
import { CARD_BACK_ICON as BACK_ICON, CARD_FRONT_ICON as FRONT_ICON, PLUGIN_ICON, UIAssistant } from "UIAssistant";
import { BaseView, BaseViewState } from "views/BaseView";
export interface ReviewViewState extends BaseViewState {
deckID?: DeckID;
showMetadata: boolean;
}
export class ReviewView extends BaseView<ReviewViewState> {
public static readonly TYPE = "come-through-view-review";
public static createViewState(deckID: DeckID | undefined, showMetadata: boolean = false): ReviewViewState {
return {
deckID: deckID,
showMetadata: showMetadata,
} satisfies ReviewViewState;
}
private readonly data: DataStore;
private readonly ui: UIAssistant;
private readonly scheduler: Scheduler;
constructor(
leaf: WorkspaceLeaf,
settingsManager: SettingsManager,
scheduler: Scheduler,
ui: UIAssistant,
data: DataStore) {
super(leaf, settingsManager, {
data: data,
});
this.scheduler = scheduler;
this.ui = ui;
this.data = data;
this.reviewDate = new Date(); // Will be refreshed as each item is presented.
this.navigation = true;
this.scope = new Scope(this.app.scope);
const toggleFrontBackEventListener: KeymapEventListener = (_evt, _ctx) => {
this.toggleAnswer();
return false;
};
const rate = (grade: Grade) => {
if (this.reviewedItem && this.eState.showBackSide)
this.rate(this.reviewedItem.id, grade);
};
this.scope.register(null, " ", toggleFrontBackEventListener);
this.scope.register(null, "Enter", toggleFrontBackEventListener);
if (!InternalApi.addEventHandlerToExistingKeyMap(this.app, this.scope, "markdown:toggle-preview", toggleFrontBackEventListener))
this.scope.register(["Mod"], "E", toggleFrontBackEventListener);
this.scope.register(null, "1", () => rate(Rating.Again));
this.scope.register(null, "2", () => rate(Rating.Hard));
this.scope.register(null, "3", () => rate(Rating.Good));
this.scope.register(null, "4", () => rate(Rating.Easy));
this.scope.register(["Mod"], "R", this.refreshView);
}
public override getIcon() {
return PLUGIN_ICON;
}
public getViewType() {
return ReviewView.TYPE;
}
public getDisplayText() {
return this.deck ? `Review: ${this.deck.data.n}` : "Review";
}
public override onload(): void {
super.onload();
if (Env.isMobile)
this.registerDomEvent(this.contentEl, "dblclick", () => { this.toggleAnswer(); });
}
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
super.onPaneMenu(menu, source);
if (source === "tab-header")
return;
const file = this.sourceFile();
if (!file)
return;
this.ui.addMenuItem(menu, `Open source note`, {
section: "pane",
icon: "file-code-2",
prefix: false,
onClick: async evt => {
const leaf = this.app.workspace.getLeaf(Keymap.isModEvent(evt));
await leaf.openFile(file, { state: undefined, eState: undefined, active: true, group: undefined });
}
});
this.ui.addMenuItem(menu, `Show metadata`, {
section: "pane",
icon: "file-text",
prefix: false,
checked: this.showMetadata,
onClick: () => {
this.showMetadata = !this.showMetadata;
this.saveState();
this.refreshView();
}
});
this.ui.addMenuItem(menu, "Reload", {
section: "pane",
icon: "refresh-cw",
prefix: false,
onClick: this.refreshView
});
}
protected async onOpen() {
await super.onOpen();
this.toggleAnswerButton = this.addAction(
this.eState.showBackSide ? FRONT_ICON : BACK_ICON,
this.eState.showBackSide ? "View front" : "View back",
() => this.toggleAnswer());
this.frontContainer = createDiv();
this.backContainer = createDiv();
}
protected onSetState(state: ReviewViewState, result: ViewStateResult): void {
this.deck = state.deckID ? {
id: state.deckID,
data: this.data.getDeck(state.deckID, true)!
} : undefined;
this.showMetadata = state.showMetadata;
this.resetEphemeralState();
}
protected onGetState(): ReviewViewState {
return {
deckID: this.deck?.id,
showMetadata: this.showMetadata,
}
}
/**
* @returns The {@link TFile} where the currently displayed side is declared, `undefined` if there's no side dislayed, `null` if the file couldn't be found.
*/
private sourceFile(): TFile | null | undefined {
return this.eState.currentCard && this.app.vault.getFileByPath(this.eState.showBackSide ? this.eState.currentCard.backID.noteID : this.eState.currentCard.frontID.noteID);
}
private deck?: DeckIDDataTuple;
private showMetadata = false;
/** The current item in review. */
private reviewedItem?: { id: FullID, statistics: StatisticsData } | null;
private reviewDate: Date;
private readonly eState = {
currentCard: null as ParsedCard | null,
/** If `true` then currently showing the answer. */
showBackSide: false,
lastFrontScrollPosition: 0,
lastBackScrollPosition: 0
};
/** Where the front side's DOM is appened to. */
private frontContainer: HTMLDivElement;
/** Where the back side's DOM is appened to. */
private backContainer: HTMLDivElement;
private ratingButtonsContainer?: HTMLDivElement;
private toggleAnswerButton?: HTMLElement;
protected async onRender() {
this.frontContainer.remove();
this.frontContainer.empty();
this.backContainer.remove();
this.backContainer.empty();
this.ratingButtonsContainer?.remove();
this.ratingButtonsContainer?.empty();
this.ratingButtonsContainer = undefined;
const onAbort = () => {
this.toggleAnswerButton?.hide();
this.resetEphemeralState();
};
try {
const cards = this.data.getAllCardsForDeck(this.deck?.id);
if (cards.length == 0) {
this.viewAssistant.createPara({
text: `There are no cards in ${this.deck ? `the deck named ${this.deck.data.n}` : "this vault"}.`
});
onAbort();
return;
}
this.reviewDate = new Date();
this.reviewedItem = this.scheduler.getNextItem(cards, this.reviewDate);
if (!this.reviewedItem) {
this.viewAssistant.createPara({ text: `No more cards at the moment. All ${cards.length} cards ${this.deck ? `under ${this.deck.data.n}` : "in this vault"} are done.` });
onAbort();
return;
}
console.assert(this.reviewedItem.id.cardID, "Card expected.");
if (this.reviewedItem.id.cardID === undefined) {
onAbort();
return;
}
const maybeCompleteCard = await ContentParser.getCard(this.reviewedItem.id, this.app, {
contentRead: {
hideCardSectionMarker: this.settingsManager.settings.hideCardSectionMarker
},
likelyNoteIDs: this.data.getAllNotes() // Only notes that contain declarations
});
if (maybeCompleteCard.complete === null) {
if (maybeCompleteCard.complete === null && maybeCompleteCard.incomplete === null)
this.viewAssistant.createPara({ text: `Could not find content of "${this.reviewedItem.id.cardID}" in "${this.reviewedItem.id.noteID}".` });
if (maybeCompleteCard.incomplete !== null)
this.viewAssistant.createPara({ text: `"${this.reviewedItem.id.toString()}" does not have a ${!maybeCompleteCard.incomplete.backMarkdown ? "back" : "front"} side.` });
onAbort();
return;
}
this.eState.currentCard = maybeCompleteCard.complete;
} catch (error: unknown) {
this.handleError(error);
onAbort();
return;
}
const parsedCard = this.eState.currentCard;
const processors = [new HeadingProcessor(this.settingsManager.settings.hideCardSectionMarker ? 2 : 1)];
this.contentRenderer.addCustomProcessors(processors);
await this.contentRenderer.render(
parsedCard.frontMarkdown.trim().length > 0 ? parsedCard.frontMarkdown : "Empty front side",
this.frontContainer, {
sourcePath: parsedCard.frontID.noteID,
});
await this.contentRenderer.render(
parsedCard.backMarkdown.trim().length > 0 ? parsedCard.backMarkdown : "Empty back side",
this.backContainer, {
sourcePath: parsedCard.backID.noteID,
});
this.contentRenderer.removeCustomProcessors(processors);
this.viewAssistant.contentEl.appendChild(this.eState.showBackSide ? this.backContainer : this.frontContainer);
const nextItems = this.scheduler.previewNextItem(this.reviewedItem.statistics, this.reviewDate);
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
this.createRatingButtons(nextItems, ratingButtonsContainer);
this.ratingButtonsContainer = this.viewAssistant.contentEl.appendChild(ratingButtonsContainer);
if (!this.eState.showBackSide)
ratingButtonsContainer.hide();
this.toggleAnswerButton?.show();
if (this.showMetadata)
this.renderMetadata();
this.setRatingButtonWidths();
}
private createRatingButtons(nextItems: IPreview, ratingButtonsContainer: HTMLDivElement) {
// If width is not sufficient for four buttons on one line, this container is used to make two buttons wrap instead of one at the time.
let pairContainer: HTMLDivElement | null = null;
const ratingButtons: HTMLButtonElement[] = [];
for (const { log: nextLog, card: nextCard } of nextItems) {
if (ratingButtons.length % 2 == 0 || pairContainer === null)
pairContainer = ratingButtonsContainer.createDiv({ cls: "button-pair" });
ratingButtons.push(pairContainer.createEl("button", undefined, (button) => {
setTooltip(button, `${TypeConvert.time(nextCard.due).toString()}`)
const buttonText = button.createDiv();
buttonText.createSpan({
text: Rating[nextLog.rating],
});
buttonText.createSpan({
text: `${show_diff_message(nextCard.due, this.reviewDate, true, this.timeUnit)}`,
});
this.contentRenderer.registerDomEvent(button, "click", async () => {
if (this.reviewedItem)
await this.rate(this.reviewedItem.id, nextLog.rating as Grade);
})
}));
}
}
private handleError(error: unknown) {
if (error instanceof FileParserError) {
if (error.type === "file cache unavailable") {
this.viewAssistant.createPara({
text: `Cannot display card because "${error.file.path}" is not indexed.`
});
const reloadButton = this.viewAssistant
.createPara()
.createEl("button", { text: "Reload Obsidian" });
this.registerDomEvent(reloadButton, "click", async () => {
InternalApi.reloadApp(this.app);
})
}
}
else if (error instanceof Error) {
this.viewAssistant.createPara({ text: error.message });
} else {
this.viewAssistant.createPara({ text: `Unexpected error` });
}
Env.error(error);
}
private async rate(id: FullID, grade: Grade) {
// Use now as scheduling date rather than the date of rendering to avoid items being scheduled as due in the past, e.g., when next interval is 1 min.
this.scheduler.rateItem(id, grade);
this.ui.displayNotice(`You rated ${Rating[grade]}`);
this.resetEphemeralState();
await this.data.save(); // This will trigger a refresh via the registered change callback.
}
private resetEphemeralState() {
this.eState.showBackSide = false;
this.eState.lastFrontScrollPosition = 0;
this.eState.lastBackScrollPosition = 0;
this.eState.currentCard = null;
}
private toggleAnswer() {
const scrollPosition = this.scrollPosition;
if (this.displayCard(!this.eState.showBackSide)) {
this.eState.showBackSide = !this.eState.showBackSide
if (this.eState.showBackSide)
this.eState.lastFrontScrollPosition = scrollPosition.top;
else
this.eState.lastBackScrollPosition = scrollPosition.top;
this.setScrollPosition({
top: this.eState.showBackSide ? this.eState.lastBackScrollPosition : this.eState.lastFrontScrollPosition,
behavior: "instant"
});
}
}
private displayCard(showFront: boolean) {
if (!this.reviewedItem)
return false;
if (showFront) {
// Uncaught NotFoundError: Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.
console.assert(this.frontContainer.parentElement);
this.viewAssistant.contentEl.replaceChild(this.backContainer, this.frontContainer);
this.ratingButtonsContainer?.show();
}
else {
console.assert(this.backContainer.parentElement);
this.viewAssistant.contentEl.replaceChild(this.frontContainer, this.backContainer);
this.ratingButtonsContainer?.hide();
}
if (this.toggleAnswerButton) {
setTooltip(this.toggleAnswerButton, showFront ? "View front" : "View back");
setIcon(this.toggleAnswerButton, showFront ? FRONT_ICON : BACK_ICON);
}
this.setRatingButtonWidths();
return true;
}
/**
* Set the width on all rating buttons to the width of the widest button based on its content.
*
* - Requires that the button's initial `width` style is set to `"auto"`.
*/
private setRatingButtonWidths() {
if (!this.ratingButtonsContainer)
return;
if (this.ratingButtonsContainer.dataset.didAdjustButtons)
return;
// The container needs to be in the DOM otherwise widths may not be available.
if (!this.ratingButtonsContainer.isShown())
return;
const ratingButtons = this.ratingButtonsContainer.querySelectorAll("button");
if (!ratingButtons)
return;
let maxWidth = 0;
ratingButtons.forEach((btn) => {
const width = btn.offsetWidth;
if (width > maxWidth)
maxWidth = width;
});
ratingButtons.forEach(btn => btn.setCssProps({ "width": maxWidth + "px" }));
this.ratingButtonsContainer.dataset.didAdjustButtons = "yes";
}
private renderMetadata() {
if (!this.eState.currentCard || !this.reviewedItem)
return;
const stats = this.reviewedItem.statistics;
const el = this.viewAssistant.contentEl.createDiv({ cls: "statistics" });
el.createEl("span", { text: `IDs: ${this.eState.currentCard.frontID} / ${this.eState.currentCard.backID}` });
el.createEl("span", { text: `State: ${State[stats.st]} (${stats.st})` });
el.createEl("span", { text: `${this.scheduler.isStatisticsDue(stats, this.reviewDate) ? "Due now" : "Not yet due:"}: ${TypeConvert.time(stats.due).toString()}` });
if (stats.lr) {
el.createEl("span", { text: `Last review: ${TypeConvert.time(stats.lr).toString()}` });
}
el.createEl("span", { text: `Retrievability: now: ${this.scheduler.retrievability(stats, this.reviewDate)}, @due: ${this.scheduler.retrievability(stats, stats.due)}` });
el.createEl("span", { text: `Learning steps: ${stats.ls}` });
el.createEl("span", { text: `Scheduled days: ${stats.sd}` });
el.createEl("span", { text: `Lapses: ${stats.l}` });
}
private readonly timeUnit = [' sec', ' min', ' hours', ' days', ' months', ' years'];
}

3
src/views/constants.ts Normal file
View file

@ -0,0 +1,3 @@
export const CssClass = {
WORKSPACE_LEAF_CONTENT_MODIFIER: "come-through-workspace-leaf-content"
} as const;

View file

@ -0,0 +1,69 @@
import { Env } from "env";
import { UnsignedInteger } from "types";
import { Num } from "utils/ts";
export class ContentUnit {
private pages: (HTMLDivElement | undefined)[];
constructor(numberOfPages: number) {
this.pages = new Array(numberOfPages).fill(undefined);
}
/**
* @returns The index of the element that is attached or `null` if none is.
*/
public get currentIndex(): UnsignedInteger | null {
let firstAttached: number | undefined;
let numberOfAttached = 0;
this.pages.forEach((element, index) => {
if (element !== undefined && element.isConnected) {
if (firstAttached === undefined)
firstAttached = index;
numberOfAttached += 1;
}
});
Env.dev?.assert(numberOfAttached <= 1, "More than one item element is attached to the DOM.");
return firstAttached !== undefined ? Num.UInt.create(firstAttached) : null;
}
public get isAtLastIndex() {
if (this.pages.length === 0)
return false;
const p = this.pages[this.pages.length - 1];
if (p === undefined)
return false;
return p.isConnected;
}
public get nextIndexUp() {
const i = this.currentIndex;
if (i === null)
return null;
return Num.UInt.create(i === this.pages.length - 1 ? 0 : i + 1);
}
public getPageAtIndex(index: UnsignedInteger): HTMLDivElement | null {
return this.pages[index] ?? null;
}
private removePageAtIndex(index: UnsignedInteger) {
const element = this.getPageAtIndex(index);
if (element !== null) {
element.remove();
element.empty();
}
this.pages[index] = undefined;
}
public removeAllPages() {
for (let i = 0; i < this.pages.length; i++)
this.removePageAtIndex(Num.UInt.create(i));
}
public setPageAtIndex(index: UnsignedInteger, content: HTMLDivElement) {
Env.dev?.assert(index < this.pages.length);
this.pages[index] = content;
}
}

View file

@ -0,0 +1,660 @@
import { ContentParser } from "ContentParser";
import { DataStore, DeckIDDataTuple } from "data/DataStore";
import { DeckID } from "data/FullID";
import { Env } from "env";
import t from "Localization";
import { ReviewItemInfoModal } from "modals/ReviewItemInfoModal";
import { IconName, Keymap, KeymapEventListener, Menu, PaneType, Scope, setIcon, setTooltip, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
import { HeadingProcessor } from "renderings/content/HeadingProcessor";
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
import { Scheduler } from "scheduling/Scheduler";
import { Rating } from "scheduling/types";
import { NextReviewItemOptions, ReviewSortOrder } from "scheduling/types";
import { SettingsManager } from "Settings";
import { OmitIndexSignature, UnsignedInteger } from "types";
import { Icon } from "ui/constants";
import { UIAssistant } from "ui/UIAssistant";
import { FileParserError } from "utils/obs/FileParser";
import { InternalApi } from "utils/obs/internal";
import { Num, Obj, Str } from "utils/ts";
import { BaseView, BaseViewEphemeralState, BaseViewState } from "views/BaseView";
import { ContentUnit } from "views/review/ContentUnit";
import { createMetadataEl, createRatingButtons } from "views/review/elements";
import { ReviewState } from "views/review/types";
interface ReviewViewState extends BaseViewState {
deckID: DeckID | null;
showMetadata: boolean;
sortOrder: ReviewSortOrder;
}
interface ReviewViewEphemeralState extends BaseViewEphemeralState {
contentIndex: UnsignedInteger | null;
frontScrollPosition: number;
backScrollPosition: number;
};
const DEFAULT_STATE: ReviewViewState = {
deckID: null,
showMetadata: false,
sortOrder: "due",
} as const;
const DEFAULT_ESTATE: ReviewViewEphemeralState = {
contentIndex: null,
frontScrollPosition: 0,
backScrollPosition: 0
} as const;
export class ReviewView extends BaseView<ReviewViewState> {
public static readonly TYPE = "come-through-view-review";
public static createViewState(deckID: DeckID | null): ReviewViewState {
Env.log.d("ReviewView:createViewState: collection ID:", deckID);
return {
...DEFAULT_STATE,
...{
deckID: deckID,
},
} satisfies ReviewViewState;
}
private readonly data: DataStore;
private readonly ui: UIAssistant;
private readonly scheduler: Scheduler;
private readonly pager: ContentUnit;
private state: ReviewViewState = { ...DEFAULT_STATE };
private eState: ReviewViewEphemeralState = { ...DEFAULT_ESTATE };
/**
* Will be `null` if there for some reason is no content available.
* For example if:
* - there was no content to review based on the given collection and sort order
* - errors occured during the process
* - an instance of this class was just created and this property hasn't been assigned yet
*
* External available functionality that is dependent on this value, is exposed through {@link getFacade}, which may be used internally as well.
*/
private reviewState: ReviewState | null = null;
private deck: DeckIDDataTuple | null = null;
private ratingButtonsContainer?: HTMLDivElement;
private displayNextContentButton?: HTMLElement;
public constructor(
leaf: WorkspaceLeaf,
settingsManager: SettingsManager,
scheduler: Scheduler,
ui: UIAssistant,
data: DataStore) {
Env.log.d("ReviewView:constructor");
super(leaf, settingsManager, { data: data });
this.scheduler = scheduler;
this.ui = ui;
this.data = data;
this.pager = new ContentUnit(2);
this.navigation = true;
this.scope = new Scope(this.app.scope);
const onDisplayNextContentItem: KeymapEventListener = () => {
this.displayNextContentItem();
return false;
};
const rate = (rating: Rating) => {
this.getFacade()?.rate?.(rating);
};
this.scope.register(null, " ", onDisplayNextContentItem);
this.scope.register(null, "Enter", onDisplayNextContentItem);
if (!InternalApi.addEventHandlerToExistingKeyMap(this.app, this.scope, "markdown:toggle-preview", onDisplayNextContentItem))
this.scope.register(["Mod"], "E", onDisplayNextContentItem);
this.scope.register(null, "1", () => rate(Rating.Again));
this.scope.register(null, "2", () => rate(Rating.Hard));
this.scope.register(null, "3", () => rate(Rating.Good));
this.scope.register(null, "4", () => rate(Rating.Easy));
this.scope.register(["Mod"], "R", () => this.reloadView());
}
public setSortOrder(sortOrder: ReviewSortOrder) {
this.state.sortOrder = sortOrder;
this.removeAllPages(true);
this.saveState();
this.render();
}
/**
* @returns `null` if it is not possible to call any of the methods.
*/
public getFacade() {
if (this.reviewState === null)
return null;
const reviewState = this.reviewState;
return {
/** @returns `null` if source file cannot be opened. */
openSourceFile: this.sourceFile() instanceof TFile ? async (newLeaf?: PaneType | boolean) => {
const sourceFile = this.sourceFile();
if (sourceFile) {
const leaf = this.app.workspace.getLeaf(newLeaf);
await leaf.openFile(sourceFile, { state: undefined, eState: undefined, active: true, group: undefined });
}
} : null,
rate: this.pager.isAtLastIndex ? (rating: Rating) => this.rate(rating) : null,
reviewState: reviewState,
reviewItemInfo: () => this.scheduler.getItemInfo(reviewState.reviewedItem),
sortOrder: this.state.sortOrder,
/** @returns `null` if metadata cannot be shown. */
toggleShowMetadata: this.pager.currentIndex !== null ? () => {
this.state.showMetadata = !this.state.showMetadata;
this.saveState();
this.render();
} : null,
};
}
public override getIcon(): IconName {
return Icon.PLUGIN;
}
public override getViewType(): string {
return ReviewView.TYPE;
}
public override getDisplayText(): string {
return this.deck ? `Review: ${this.deck.data.n}` : "Review";
}
public override onload(): void {
Env.log.d("ReviewView:onload");
super.onload();
if (Env.isMobile)
this.registerDomEvent(this.contentEl, "dblclick", () => { this.displayNextContentItem(); });
}
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
Env.log.d("ReviewView:onPaneMenu");
super.onPaneMenu(menu, source);
if (source === "tab-header")
return;
const prefix = false;
const section = "pane";
const action = this.getFacade();
this.ui.addMenuItem(menu, `Order by retrievability`, {
section: section,
icon: "arrow-up-down",
prefix: prefix,
checked: this.state.sortOrder === "retrievability",
onClick: () => {
this.setSortOrder(this.state.sortOrder === "retrievability" ? "due" : "retrievability");
}
});
if (action !== null && action.openSourceFile !== null) {
this.ui.addMenuItem(menu, `Open source note`, {
section: section,
icon: "file-code-2",
prefix: prefix,
onClick: async evt => {
await action.openSourceFile?.(Keymap.isModEvent(evt));
}
});
}
if (action !== null && action.toggleShowMetadata !== null) {
this.ui.addMenuItem(menu, t.review.actions.toggleInlineInfo, {
section: section,
icon: "info",
prefix: prefix,
checked: this.state.showMetadata,
onClick: action.toggleShowMetadata
});
}
this.ui.addMenuItem(menu, "Reload", {
section: section,
icon: "refresh-cw",
prefix: prefix,
onClick: () => {
this.reloadView();
}
});
}
protected override async onOpen(): Promise<void> {
Env.log.d("ReviewView:onOpen");
await super.onOpen();
const props = this.forwardBackwardButtonProps();
this.displayNextContentButton = this.addAction(props.icon, props.title, () => this.displayNextContentItem());
}
protected override onSetState(state: ReviewViewState, result: ViewStateResult): void {
Env.log.d("ReviewView:onSetState", state);
this.state = { ...DEFAULT_STATE, ...state };
this.deck = state.deckID ? {
id: state.deckID,
data: this.data.getDeck(state.deckID, true)!
} : null;
}
protected override onGetState(): ReviewViewState {
Env.log.d("ReviewView:onGetState");
return {
...this.state,
...{
deckID: this.deck?.id ?? null,
} satisfies Partial<OmitIndexSignature<ReviewViewState>>,
};
}
protected override onSetEphemeralState(state: unknown): void {
this.eState = Obj.is(state) ? { ...DEFAULT_ESTATE, ...state } : { ...DEFAULT_ESTATE };
Env.log.d("ReviewView:onSetEphemeralState:", this.eState);
if (this.eState.contentIndex !== null)
this.displayPageAtIndex(this.eState.contentIndex);
this.refreshUI(); // Other UI dependent on ephemeral state is updated here.
}
protected override onGetEphemeralState(): Record<string, unknown> {
Env.log.d("ReviewView:onGetEphemeralState");
this.updateEphemeralState({
currentPageIndex: this.pager.currentIndex,
scrollPositionForPageIndex: this.pager.currentIndex ?? Num.UInt.create(0),
});
return this.eState;
}
protected override async onRender(): Promise<void> {
Env.log.d("ReviewView:onRender");
this.ratingButtonsContainer?.remove();
this.ratingButtonsContainer?.empty();
this.ratingButtonsContainer = undefined;
// Get review unit data.
this.reviewState = await this.getNextItem();
if (this.reviewState === null) {
this.removeAllPages(true); // The pages are tied to the review state.
this.refreshUI();
return;
}
const reviewState = this.reviewState;
// Create review content
const processors = [new HeadingProcessor(this.settingsManager.settings.hideCardSectionMarker ? 2 : 1)];
this.contentRenderer.addCustomProcessors(processors);
const frontIndex = Num.UInt.create(0);
let frontContainer = this.pager.getPageAtIndex(frontIndex);
Env.log.view("Will render content page:", frontIndex, frontContainer === null);
if (frontContainer === null) {
frontContainer = createDiv();
await this.contentRenderer.render(
reviewState.card.frontMarkdown.trim().length > 0 ? reviewState.card.frontMarkdown : "Empty front side",
frontContainer, {
sourcePath: reviewState.card.frontID.noteID,
});
this.pager.setPageAtIndex(frontIndex, frontContainer);
}
const backIndex = Num.UInt.create(1);
let backContainer = this.pager.getPageAtIndex(backIndex);
Env.log.view("Will render content page:", backIndex, backContainer === null);
if (backContainer === null) {
backContainer = createDiv();
await this.contentRenderer.render(
reviewState.card.backMarkdown.trim().length > 0 ? reviewState.card.backMarkdown : "Empty back side",
backContainer, {
sourcePath: reviewState.card.backID.noteID,
});
this.pager.setPageAtIndex(backIndex, backContainer);
}
this.contentRenderer.removeCustomProcessors(processors);
this.displayPageAtIndex(this.pager.currentIndex ?? Num.UInt.create(0));
// Create additional UI components.
const nextItems = this.scheduler.previewNextItem(reviewState.reviewedItem.statistics, reviewState.date).map(nextItem => ({
info: this.scheduler.getItemInfo({ id: reviewState.reviewedItem.id, statistics: nextItem.stat }),
item: nextItem,
}));
const ratingButtonsContainer = createRatingButtons(nextItems, reviewState, this.state.sortOrder, (button: HTMLButtonElement, rating: Rating) => {
this.contentRenderer.registerDomEvent(button, "click", () => this.rate(rating));
});
this.ratingButtonsContainer = this.dom.contentEl.appendChild(ratingButtonsContainer);
if (this.state.showMetadata && !this.pager.isAtLastIndex) {
const info = this.scheduler.getItemInfo(reviewState.reviewedItem);
createMetadataEl(reviewState, this.state.sortOrder, info, this.dom.create, (button) => {
this.contentRenderer.registerDomEvent(button, "click", () => new ReviewItemInfoModal(this.app, reviewState, this.state.sortOrder, info).open());
});
}
this.refreshUI();
}
/** Currently assumes that {@link getNextItem} returns the same item again. */
private reloadView() {
// Currently first page is loaded if the pager has no `currentIndex`.
this.removeAllPages(true);
this.render();
}
/** Tries to set {@link reviewedItem} and {@link currentCard}. */
private async getNextItem() {
Env.log.d("ReviewView:getNextItem");
const nextItemOptions = (relativeDate: Date): NextReviewItemOptions => {
const totalDueBefore = new Map<Date, number>();
const noon = new Date(relativeDate);
noon.setHours(12, 0, 0, 0);
if (relativeDate.getTime() < noon.getTime())
totalDueBefore.set(noon, 0);
const eightPM = new Date(relativeDate);
eightPM.setHours(20, 0, 0, 0);
if (relativeDate.getTime() < eightPM.getTime())
totalDueBefore.set(eightPM, 0);
const fourAMNextDay = new Date(relativeDate);
fourAMNextDay.setDate(fourAMNextDay.getDate() + 1);
fourAMNextDay.setHours(4, 0, 0, 0);
totalDueBefore.set(fourAMNextDay, 0);
return {
sortOrder: this.state.sortOrder,
totalDueBefore: this.state.sortOrder === "due" ? totalDueBefore : undefined,
totalRetrievabilityBelow: this.state.sortOrder === "retrievability" ? new Map<number, number>([[0.85, 0], [0.90, 0], [0.95, 0]]) : undefined,
};
};
const getReviewState = async (): Promise<ReviewState | null> => {
const cards = this.data.getAllCardsForDeck(this.deck?.id);
if (cards.length === 0) {
Env.log.view("ReviewView:getNextItem: cards.length === 0");
this.dom.create.para({
text: `There are no cards in ${this.deck ? `the deck named ${this.deck.data.n}` : "this vault"}.`
});
return null;
}
const now = new Date();
const options = nextItemOptions(now);
const reviewedItem = this.scheduler.getNextItem(cards, now, options);
if (!reviewedItem) {
this.dom.create.para({ text: `No more cards at the moment. All ${cards.length} cards ${this.deck ? `under ${this.deck.data.n}` : "in this vault"} are done.` });
return null;
}
Env.assert(Str.nonEmpty(reviewedItem.id.cardID) !== undefined, "Card expected");
if (Str.nonEmpty(reviewedItem.id.cardID) === undefined)
return null;
const contentResult = await ContentParser.getCard(reviewedItem.id, this.app, {
contentRead: {
hideCardSectionMarker: this.settingsManager.settings.hideCardSectionMarker
},
likelyNoteIDs: this.data.getAllNotes() // Only notes that contain declarations
});
if (contentResult.complete === null) {
if (contentResult.incomplete !== null)
this.dom.create.para({ text: `"${reviewedItem.id.toString()}" does not have a ${contentResult.incomplete.backMarkdown ? "back" : "front"} side.` });
else
this.dom.create.para({ text: `Could not find content of "${reviewedItem.id.cardID}" in "${reviewedItem.id.noteID}".` });
return null;
}
return {
reviewedItem,
date: now,
card: contentResult.complete,
numberOfItems: cards.length,
totalDueBefore: options.totalDueBefore,
totalRetrievabilityBelow: options.totalRetrievabilityBelow
};
};
const handleError = (error: unknown) => {
Env.log.e("ReviewView:getNextItem:handleError:", error);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (error instanceof FileParserError && error.type === "file cache unavailable") {
this.dom.create.para({ text: `Cannot display card because "${error.file.path}" is not indexed.` });
this.dom.create.paraWrapper().createEl("button", { text: "Reload Obsidian" }, (el) => {
this.registerDomEvent(el, "click", () => { InternalApi.reloadApp(this.app); })
});
}
else if (error instanceof Error) {
this.dom.create.para({ text: error.message });
}
else {
this.dom.create.para({ text: "Could not retrieve content" });
}
}
let rs: ReviewState | null = null;
try {
rs = await getReviewState();
} catch (error: unknown) {
handleError(error);
}
return rs;
}
private async rate(rating: Rating) {
Env.log.d("ReviewView:rate", rating);
const item = this.reviewState?.reviewedItem;
Env.assert(item !== undefined);
if (item === undefined)
return;
this.removeAllPages(true);
// Use now as scheduling date rather than the date of rendering to avoid items being scheduled as due in the past, e.g., when next interval is 1 min.
this.scheduler.rateItem(item.id, rating);
await this.data.save(); // This will trigger a refresh via the registered change callback.
this.ui.displayNotice(`You rated ${ReviewItemInfo.Convert.ratingAsString(rating)}`, { prefix: false });
}
private displayNextContentItem() {
Env.log.d("ReviewView:displayNextContentItem");
const nextIndex = this.pager.nextIndexUp;
Env.dev?.assert(nextIndex !== null);
if (nextIndex !== null)
this.displayPageAtIndex(nextIndex);
this.refreshUI();
}
private displayPageAtIndex(index: UnsignedInteger): boolean {
Env.log.d("ReviewView:displayContentAtIndex:", index, "review", this.reviewState);
Num.UInt.assert(index);
if (!Num.UInt.is(index))
return false;
if (this.reviewState === null)
return false;
const currentIndex = this.pager.currentIndex;
if (index === currentIndex)
return false;
// Save scroll position before DOM change.
this.updateEphemeralState({ scrollPositionForPageIndex: currentIndex ?? undefined });
const contentToDisplay = this.pager.getPageAtIndex(index); // TODO: create content here.
if (contentToDisplay === null)
return false;
const currentContent = currentIndex !== null ? this.pager.getPageAtIndex(currentIndex) : null;
// First time appending.
if (currentContent === null) {
this.dom.contentEl.appendChild(contentToDisplay);
}
else {
Env.assert(currentContent.parentElement); // Uncaught NotFoundError: Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.
this.dom.contentEl.replaceChild(contentToDisplay, currentContent);
}
Env.dev?.assert(this.pager.currentIndex === index);
this.updateEphemeralState({ currentPageIndex: index });
return true;
}
/**
* Removes all cached pages/divs created from the last review unit.
*
* @param includeEphemeralState Explicitly declare intent to reset ephemeral state
*/
private removeAllPages(includeEphemeralState: boolean) {
this.pager.removeAllPages();
if (includeEphemeralState)
this.resetEphemeralState();
}
/** Updates the state to reflect the state of its dependent UI components. */
private updateEphemeralState(p: {
currentPageIndex?: UnsignedInteger | null,
/** The page index to set the current scroll position to. */
scrollPositionForPageIndex?: UnsignedInteger
}) {
Env.log.d("ReviewView:updateEphemeralState:", p);
if (Num.UInt.is(p.currentPageIndex) || p.currentPageIndex === null) {
this.eState.contentIndex = p.currentPageIndex;
}
if (Num.UInt.is(p.scrollPositionForPageIndex)) {
if (p.scrollPositionForPageIndex === 0)
this.eState.frontScrollPosition = this.scrollPosition.top;
if (p.scrollPositionForPageIndex === 1)
this.eState.backScrollPosition = this.scrollPosition.top;
}
Env.log.view("ReviewView:updateEphemeralState: Updated ephemeral state:", this.eState);
}
private resetEphemeralState() {
Env.log.d("ReviewView:resetEphemeralState");
this.eState = { ...DEFAULT_ESTATE };
}
/** Replace with {@link ContentUnit.isAtLastIndex} when supporting more than two pages. Currently only used where two pages is hardcoded. */
private get isShowingBackSide() {
return this.pager.currentIndex === 1;
}
private refreshUI() {
Env.log.d("ReviewView:refreshUI");
if (this.reviewState !== null) {
if (this.displayNextContentButton) {
const props = this.forwardBackwardButtonProps();
setTooltip(this.displayNextContentButton, props.title);
setIcon(this.displayNextContentButton, props.icon);
this.displayNextContentButton.show();
}
}
else {
this.displayNextContentButton?.hide();
}
if (this.pager.isAtLastIndex) {
this.ratingButtonsContainer?.show();
this.setRatingButtonWidths();
}
else {
this.ratingButtonsContainer?.hide();
}
this.setScrollPosition({
top: this.isShowingBackSide ? this.eState.backScrollPosition : this.eState.frontScrollPosition,
behavior: "instant"
});
}
private forwardBackwardButtonProps() {
return {
title: this.isShowingBackSide ? "View front" : "View back",
icon: this.isShowingBackSide ? Icon.CARD_FRONT : Icon.CARD_BACK,
}
}
/**
* Set the width on all rating buttons to the width of the widest button based on its content.
*
* - Requires that the button's initial `width` style is set to `"auto"`.
*/
private setRatingButtonWidths() {
Env.log.d("ReviewView:setRatingButtonWidths");
if (!this.ratingButtonsContainer)
return;
if (this.ratingButtonsContainer.dataset.didAdjustButtons)
return;
// The container needs to be in the DOM otherwise widths may not be available.
if (!this.ratingButtonsContainer.isShown())
return;
const ratingButtons = this.ratingButtonsContainer.querySelectorAll("button");
if (ratingButtons.length === 0)
return;
let maxWidth = 0;
ratingButtons.forEach((btn) => {
const width = btn.offsetWidth;
if (width > maxWidth)
maxWidth = width;
});
ratingButtons.forEach(btn => btn.setCssProps({ "width": maxWidth + "px" }));
this.ratingButtonsContainer.dataset.didAdjustButtons = "yes";
}
/**
* @returns The {@link TFile} where the currently displayed side is declared; `null` if there's no side dislayed or if the file couldn't be found.
*/
private sourceFile(): TFile | null | undefined {
Env.log.d("ReviewView:sourceFile");
if (this.reviewState === null)
return null;
return this.app.vault.getFileByPath(this.isShowingBackSide ? this.reviewState.card.backID.noteID : this.reviewState.card.frontID.noteID);
}
}

View file

@ -0,0 +1,187 @@
import { Env } from "env";
import { setTooltip } from "obsidian";
import { NextItem, Rating } from "scheduling/types";
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
import { ReviewSortOrder } from "scheduling/types";
import { DateTime } from "utils/datetime";
import { TableRowCreator, TableSectionCreator } from "utils/dom/table";
import { ElementCreator } from "utils/ElementCreator";
import { KeyValue } from "utils/ts";
import { ReviewState } from "views/review/types";
export function createRatingButtons(nextItemsWithInfo: { item: NextItem, info: ReviewItemInfo }[], reviewState: ReviewState, sortOrder: ReviewSortOrder, cb: (button: HTMLButtonElement, rating: Rating) => void) {
Env.log.d("createRatingButtons");
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
// If width is not sufficient for four buttons on one line, this container is used to make two buttons wrap instead of one at the time.
let pairContainer: HTMLDivElement | null = null;
const ratingButtons: HTMLButtonElement[] = [];
for (const next of nextItemsWithInfo) {
if (ratingButtons.length % 2 == 0 || pairContainer === null)
pairContainer = ratingButtonsContainer.createDiv({ cls: "button-pair" });
ratingButtons.push(pairContainer.createEl("button", undefined, (button) => {
setTooltip(button, next.info.due)
button.createDiv(undefined, el => {
el.createSpan({ text: ReviewItemInfo.Convert.ratingAsString(next.item.reviewLog.rating) });
el.createSpan({ text: `${ReviewItemInfo.Convert.timeDiffMsg(next.info.dueDate, reviewState.date)}` });
});
cb(button, next.item.reviewLog.rating);
}));
}
return ratingButtonsContainer;
}
export function createMetadataEl(
reviewState: ReviewState,
sortOrder: ReviewSortOrder,
info: ReviewItemInfo,
creator: ElementCreator,
showMoreInfoCb?: (button: HTMLButtonElement) => void) {
Env.log.d("createMetadataEl");
const stats = reviewState.reviewedItem.statistics;
const reviewDate = reviewState.date;
const colSpan = { "colspan": "2" };
const addDue = (row: TableRowCreator) => {
row.add((col) => {
col.add({ text: `${info.isStatisticsDue(reviewDate) ? "Due now" : "Due"}` })
col.add({ text: info.due, attr: colSpan });
});
};
const addDueTotals = (row: TableRowCreator) => {
if (!KeyValue.isNotEmpty(reviewState.totalDueBefore))
return;
const entries = Array.from(reviewState.totalDueBefore.entries());
const lastIndex = entries.length - 1;
let col = row.add((col) => {
col.add({ text: "Ratings left before…", attr: { "rowspan": `${entries.length}` } });
});
entries.forEach(([date, value], index) => {
let timeString: string;
const hours = date.getHours();
const minutes = date.getMinutes();
const today = new Date(reviewDate);
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(tomorrow.getDate() + 1);
const isToday = date.getTime() >= today.getTime() && date.getTime() < tomorrow.getTime();
const isTomorrow = date.getTime() >= tomorrow.getTime() && date.getTime() < dayAfterTomorrow.getTime();
if (isToday && hours === 12 && minutes === 0) {
timeString = "noon";
} else if (isToday && hours === 20 && minutes === 0) {
timeString = "8 this evening";
} else if (isTomorrow && hours === 4 && minutes === 0) {
timeString = "4 tomorrow morning";
} else {
timeString = DateTime.toTimeString(date);
}
col.add({ text: timeString });
col.add({ text: `${value}` });
if (index < lastIndex)
col = row.add();
});
};
const addRetrievability = (row: TableRowCreator) => {
row.add((col) => {
//const hasDueDatePassed = scheduler.isStatisticsDue(stats, reviewDate);
col.add({ text: "Retrievability", attr: { "rowspan": 2 } });
col.add({ text: `Now: ${info.retrievabilityAsString(reviewDate)}`, attr: colSpan });
row.add().add({ text: `At due date: ${info.retrievabilityAsString(info.dueDate)}`, attr: colSpan });
});
};
const addRetrievabilityTotals = (row: TableRowCreator) => {
if (!KeyValue.isNotEmpty(reviewState.totalRetrievabilityBelow))
return;
const entries = Array.from(reviewState.totalRetrievabilityBelow.entries());
const lastIndex = entries.length - 1;
let col = row.add((col) => {
col.add({ text: "Ratings left below…", attr: { "rowspan": `${entries.length}` } });
});
entries.forEach(([threshold, value], index) => {
col.add({ text: `${threshold * 100}%` });
col.add({ text: `${value}` });
if (index < lastIndex)
col = row.add();
});
};
const tableBuilder = (section: TableSectionCreator) => {
section.setHeader((row) => {
row.add((col) => {
col.add({ text: "Info", attr: { colSpan: 3 } });
// col.add({ text: sortOrder, attr: colSpan });
});
});
section.addBody((row) => {
if (sortOrder === "retrievability") {
addRetrievability(row);
addRetrievabilityTotals(row);
addDue(row);
}
else {
addDue(row);
addDueTotals(row);
addRetrievability(row);
}
row.add((col) => {
col.add({ text: "Last review" })
const date = info.lastReview;
col.add({
text: date !== null ? DateTime.toString(date) : "This is the first review.",
attr: colSpan
});
});
row.add((col) => {
col.add({ text: "Ratings" })
col.add({ text: `${stats.r}`, attr: colSpan });
});
});
if (showMoreInfoCb) {
section.setFooter(row => {
row.add((col) => {
col.add({ attr: { colSpan: 3 } }, el => {
showMoreInfoCb(el.createEl("button", { text: "See more", "cls": "center" }));
});
});
});
}
};
creator.table(tableBuilder, {
wrapperClasses: ["statistics"],
});
}

14
src/views/review/types.ts Normal file
View file

@ -0,0 +1,14 @@
import { ParsedCard } from "ContentParser";
import { ReviewItem } from "scheduling/types";
export interface ReviewState {
/** The current item in review. */
reviewedItem: ReviewItem,
card: ParsedCard,
/** Total number of items in the review queue. */
numberOfItems: number,
/** Date for which this state applies. */
date: Date,
totalDueBefore?: Map<Date, number>,
totalRetrievabilityBelow?: Map<number, number>,
};

View file

@ -1,161 +0,0 @@
div.come-through-workspace-leaf-content .view-content {
padding: 0;
overflow: hidden;
}
/* <div class="markdown-reading-view" style="width: 100%; height: 100%;"> */
div.come-through-workspace-leaf-content .markdown-reading-view {
width: 100%;
height: 100%;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons {
justify-content: center;
margin-top: 4em;
flex-wrap: wrap;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons,
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons .button-pair {
display: flex;
gap: 1em;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons > div > button {
line-height: 2.4;
height: auto;
width: auto;
}
/* Counter this selector: body.is-phone:not(.is-android:not(.material-off)) button:not(.clickable-icon) */
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .view-content .rating-buttons > div > button {
justify-content: center;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button div {
display: flex;
flex-direction: column;
align-items: center;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span {
/* display: block; */
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(1) {
font-weight: bolder;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(2) {
font-weight: lighter;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .statistics {
background-color: var(--ribbon-background);
padding: 1em;
margin: 4em auto;
font-weight: lighter;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
line-height: 200%;
border-radius: 8px;
}
.block-language-comethrough,
.block-language-ct {
color: var(--text-normal);
font-style: var(--font-monospace-theme);
font-size: var(--font-text-size);
font-weight: var(--font-weight);
border-radius: var(--callout-radius);
padding: var(--callout-padding);
}
.block-language-comethrough .callout-title,
.block-language-ct .callout-title {
padding: var(--callout-title-padding);
}
.block-language-comethrough .callout-content,
.block-language-ct .callout-content {
padding: var(--callout-content-padding);
}
.callout.block-language-comethrough.error,
.callout.block-language-ct.error {
background-color: rgba(var(--color-red-rgb), 0.1);
border-width: var(--border-width);
border-color: var(--color-red);
}
.callout.block-language-comethrough .callout-title.error,
.callout.block-language-comethrough .callout-title.error .svg-icon,
.callout.block-language-ct .callout-title.error,
.callout.block-language-ct .callout-title.error .svg-icon {
color: var(--color-red);
}
.block-language-comethrough table,
.block-language-ct table {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: 100%;
table-layout: auto;
}
.block-language-ct table th:first-child,
.block-language-ct table td:first-child {
width: 40%;
}
.block-language-comethrough td.select-deck-cell,
.block-language-ct td.select-deck-cell {
display: flex;
align-items: center;
justify-content: flex-end;
}
.block-language-comethrough td.select-deck-cell select,
.block-language-ct td.select-deck-cell select {
flex-grow: 1;
background-color: inherit;
box-shadow: none;
font-style: inherit;
font-size: inherit;
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
height: auto;
}
.block-language-comethrough td.select-deck-cell button,
.block-language-ct td.select-deck-cell button {
padding: 0;
height: auto;
width: auto;
aspect-ratio: 1 / 1;
background-color: inherit;
box-shadow: none;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view details {
margin-block-start: var(--p-spacing);
}
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view summary {
cursor: pointer;
color: var(--h2-color);
font: var(--h2-font);
line-height: var(--h2-line-height);
font-size: var(--h2-size);
font-style: var(--h2-style);
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;
}

77
styles/declaration.css Normal file
View file

@ -0,0 +1,77 @@
.block-language-comethrough,
.block-language-ct {
color: var(--text-normal);
font-style: var(--font-monospace-theme);
font-size: var(--font-text-size);
font-weight: var(--font-weight);
border-radius: var(--callout-radius);
padding: var(--callout-padding);
}
.block-language-comethrough .callout-title,
.block-language-ct .callout-title {
padding: var(--callout-title-padding);
}
.block-language-comethrough .callout-content,
.block-language-ct .callout-content {
padding: var(--callout-content-padding);
}
.callout.block-language-comethrough.error,
.callout.block-language-ct.error {
background-color: rgba(var(--color-red-rgb), 0.1);
border-width: var(--border-width);
border-color: var(--color-red);
}
.callout.block-language-comethrough .callout-title.error,
.callout.block-language-comethrough .callout-title.error .svg-icon,
.callout.block-language-ct .callout-title.error,
.callout.block-language-ct .callout-title.error .svg-icon {
color: var(--color-red);
}
.block-language-comethrough table,
.block-language-ct table {
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: 100%;
table-layout: auto;
}
.block-language-ct table th:first-child,
.block-language-ct table td:first-child {
width: 40%;
}
.block-language-comethrough td.select-deck-cell,
.block-language-ct td.select-deck-cell {
display: flex;
align-items: center;
justify-content: flex-end;
}
.block-language-comethrough td.select-deck-cell select,
.block-language-ct td.select-deck-cell select {
flex-grow: 1;
background-color: inherit;
box-shadow: none;
font-style: inherit;
font-size: inherit;
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
height: auto;
}
.block-language-comethrough td.select-deck-cell button,
.block-language-ct td.select-deck-cell button {
padding: 0;
height: auto;
width: auto;
aspect-ratio: 1 / 1;
background-color: inherit;
box-shadow: none;
}

View file

@ -0,0 +1,14 @@
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view details {
margin-block-start: var(--p-spacing);
}
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view summary {
cursor: pointer;
color: var(--h2-color);
font: var(--h2-font);
line-height: var(--h2-line-height);
font-size: var(--h2-size);
font-style: var(--h2-style);
font-variant: var(--h2-variant);
font-weight: var(--h2-weight);
}

5
styles/main.css Normal file
View file

@ -0,0 +1,5 @@
@import "declaration.css";
@import "modal.css";
@import "view.css";
@import "definedContentView.css";
@import "reviewView.css";

10
styles/modal.css Normal file
View file

@ -0,0 +1,10 @@
.come-through-modal-content table {
border-spacing: 0;
border-style: none;
}
/* Make tables left aligned when in the control section that is styled as the description section. */
.come-through-modal-content .setting-item-control table {
white-space: nowrap;
text-align: left;
}

52
styles/reviewView.css Normal file
View file

@ -0,0 +1,52 @@
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons {
justify-content: center;
margin-top: var(--p-spacing);
flex-wrap: wrap;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons,
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons .button-pair {
display: flex;
gap: 1em;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons > div > button {
line-height: 2.4;
height: auto;
width: auto;
}
/* Counter this selector: body.is-phone:not(.is-android:not(.material-off)) button:not(.clickable-icon) */
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .view-content .rating-buttons > div > button {
justify-content: center;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button div {
display: flex;
flex-direction: column;
align-items: center;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(1) {
font-weight: bolder;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(2) {
font-weight: lighter;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] div.el-table.statistics:has(>table) {
padding: 0;
margin: 0;
margin-top: var(--p-spacing);
font-weight: var(--font-extralight);
display: flex;
justify-content: center;
overflow-x: auto;
}
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] div.el-table.statistics table tfoot td {
text-align: center;
border: none;
padding-top: var(--size-4-2);
}

16
styles/view.css Normal file
View file

@ -0,0 +1,16 @@
div.come-through-workspace-leaf-content .view-content {
padding: 0;
overflow: hidden;
}
/* <div class="markdown-reading-view" style="width: 100%; height: 100%;"> */
div.come-through-workspace-leaf-content .markdown-reading-view {
width: 100%;
height: 100%;
}
div.come-through-workspace-leaf-content :lang(th) {
font-size: 1.25rem;
}

View file

@ -1,19 +1,31 @@
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2022",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES2022"]
},
"include": [
"**/*.ts"
]
"compilerOptions": {
"baseUrl": "./src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2022",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
//"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"lib": [
"DOM",
"ES2022"
]
},
"include": [
"./src/**/*.ts"
],
"exclude": [
"./node_modules",
"./dist",
"./build",
"./main.js",
"./eslint.config.js"
]
}

View file

@ -1,4 +1,5 @@
{
"0.6.0": "1.8.0",
"0.5.1": "1.8.0",
"0.5.0": "1.8.0",
"0.4.2": "1.8.0",