mirror of
https://github.com/mntno/obsidian-come-through.git
synced 2026-07-22 05:47:56 +00:00
0.6.1
This commit is contained in:
parent
c9f3e55df3
commit
653820a66a
80 changed files with 3177 additions and 1783 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -18,7 +18,12 @@ data.json
|
||||||
|
|
||||||
# This plugin
|
# This plugin
|
||||||
|
|
||||||
|
## Files are copied here on `pnpm run build`.
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
## Sym-linked to the plugin's folder in the dev/test vault. Files are copied here `pnpm run dev`.
|
||||||
|
## Git stores symlinks as a special file containing the target path string, not as a directory. So the gitignore rule needs to match a file, not a directory pattern.
|
||||||
|
dev-vault
|
||||||
|
|
||||||
main.js
|
main.js
|
||||||
styles.css
|
styles.css
|
||||||
|
|
||||||
/dist/
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ id: card2
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
|
There are commands that let you insert these declarations without typing in the [Command palette](https://help.obsidian.md/plugins/command-palette).
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
>
|
>
|
||||||
> The card declaration format is plain YAML (same as Obsidian [properties](https://help.obsidian.md/properties#Property+format)) so the YAML specification has to be adhered to. For example, there must be a space between the colon and the value.
|
> The card declaration format is plain YAML (same as Obsidian [properties](https://help.obsidian.md/properties#Property+format)) so the YAML specification has to be adhered to. For example, there must be a space between the colon and the value.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import builtins from "builtin-modules";
|
import builtins from "builtin-modules";
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import process from "process";
|
import process from "process";
|
||||||
|
|
||||||
const banner =
|
const banner =
|
||||||
|
|
@ -11,7 +12,9 @@ if you want to view the source, please visit the github repository of this plugi
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const prod = (process.argv[2] === "production");
|
const prod = (process.argv[2] === "production");
|
||||||
const outdir = prod ? "dist" : ".";
|
const outdir = prod ? "dist" : "dev-vault";
|
||||||
|
|
||||||
|
fs.copyFileSync("manifest.json", path.join(outdir, "manifest.json"));
|
||||||
|
|
||||||
const context = await esbuild.context({
|
const context = await esbuild.context({
|
||||||
banner: {
|
banner: {
|
||||||
|
|
@ -39,6 +42,7 @@ const context = await esbuild.context({
|
||||||
"@lezer/lr",
|
"@lezer/lr",
|
||||||
...builtins],
|
...builtins],
|
||||||
format: "cjs",
|
format: "cjs",
|
||||||
|
// Runtime of min supported Obsidian version 1.8.2, see manifest.json
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
sourcemap: prod ? false : "inline",
|
sourcemap: prod ? false : "inline",
|
||||||
|
|
@ -52,7 +56,6 @@ const context = await esbuild.context({
|
||||||
|
|
||||||
if (prod) {
|
if (prod) {
|
||||||
await context.rebuild();
|
await context.rebuild();
|
||||||
fs.copyFileSync("manifest.json", "dist/manifest.json");
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
await context.watch();
|
await context.watch();
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,9 @@ export default defineConfig(
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tseslint.parser,
|
parser: tseslint.parser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "./tsconfig.json",
|
projectService: true,
|
||||||
projectService: "true",
|
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
ecmaVersion: "latest",
|
ecmaVersion: 2022,
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
|
|
@ -46,22 +45,34 @@ export default defineConfig(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
// You should always have "no-unused-vars": "off" alongside @typescript-eslint/no-unused-vars,
|
||||||
|
// https://typescript-eslint.io/rules/no-unused-vars/
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
"@typescript-eslint/no-unused-vars": ["error", {
|
||||||
args: "none"
|
"args": "all",
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"caughtErrors": "all",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"ignoreRestSiblings": true,
|
||||||
}],
|
}],
|
||||||
|
|
||||||
|
//
|
||||||
"@typescript-eslint/ban-ts-comment": ["error", {
|
"@typescript-eslint/ban-ts-comment": ["error", {
|
||||||
"ts-expect-error": false,
|
"ts-expect-error": false,
|
||||||
"ts-ignore": true,
|
"ts-ignore": true,
|
||||||
"ts-nocheck": true,
|
"ts-nocheck": true,
|
||||||
"ts-check": true,
|
"ts-check": true,
|
||||||
}],
|
}],
|
||||||
|
|
||||||
"no-prototype-builtins": "off",
|
"no-prototype-builtins": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-unnecessary-condition": ["warn", {
|
"@typescript-eslint/no-unnecessary-condition": ["warn", {
|
||||||
// https://typescript-eslint.io/rules/no-unnecessary-condition/#only-allowed-literals
|
// https://typescript-eslint.io/rules/no-unnecessary-condition/#only-allowed-literals
|
||||||
"allowConstantLoopConditions": "only-allowed-literals"
|
"allowConstantLoopConditions": "only-allowed-literals"
|
||||||
}],
|
}],
|
||||||
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"id": "come-through",
|
"id": "come-through",
|
||||||
"name": "Come Through",
|
"name": "Come Through",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"minAppVersion": "1.8.0",
|
"minAppVersion": "1.8.2",
|
||||||
"description": "Drill flashcards using spaced repetition.",
|
"description": "Drill flashcards using spaced repetition.",
|
||||||
"author": "mntno",
|
"author": "mntno",
|
||||||
"authorUrl": "https://github.com/mntno",
|
"authorUrl": "https://github.com/mntno",
|
||||||
|
|
|
||||||
21
package.json
21
package.json
|
|
@ -4,28 +4,29 @@
|
||||||
"description": "Drill flashcards using spaced repetition.",
|
"description": "Drill flashcards using spaced repetition.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"lint": "pnpm eslint .",
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "(pnpm eslint . || true) && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "mntno",
|
"author": "mntno",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "24.6.2",
|
"@types/node": "25.6.0",
|
||||||
"builtin-modules": "5.0.0",
|
"builtin-modules": "5.1.0",
|
||||||
"esbuild": "^0.25.10",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "^9.37.0",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-obsidianmd": "^0.1.4",
|
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||||
"fast-equals": "5.2.2",
|
"fast-equals": "5.2.2",
|
||||||
"globals": "16.4.0",
|
"globals": "17.5.0",
|
||||||
"luxon": "3.7.2",
|
"luxon": "3.7.2",
|
||||||
"obsidian": "latest",
|
"obsidian": "latest",
|
||||||
"ts-fsrs": "5.2.1",
|
"ts-fsrs": "5.2.1",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.46.1"
|
"typescript-eslint": "^8.58.1"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|
|
||||||
1004
pnpm-lock.yaml
1004
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,11 @@
|
||||||
import { CardDeclarationAssistant, IDScope } from "declarations/CardDeclaration";
|
import { CardID, FullID, NoteID } from "#/data/FullID";
|
||||||
import { Declaration } from "declarations/Declaration";
|
import { DeclarationConstants } from "#/declarations/constants";
|
||||||
import { DeclarationParser } from "declarations/DeclarationParser";
|
import { DeclarationParser } from "#/declarations/DeclarationParser";
|
||||||
import { FullSectionRange, SectionRange } from "utils/obs/FileParser";
|
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||||
import { CardID, FullID, NoteID } from "data/FullID";
|
import { asNoteID, fullIDFromDeclaration } from "#/TypeAssistant";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { FullSectionRange, SectionRange } from "#/utils/obs/FileParser";
|
||||||
import { App, CachedMetadata, HeadingCache, SectionCache, TFile } from "obsidian";
|
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}.
|
* The result of attempting to retrieve the content of a particular {@link FullID}.
|
||||||
|
|
@ -127,14 +127,16 @@ export class ContentParser extends DeclarationParser {
|
||||||
const predicate: PopulationPredicate = {
|
const predicate: PopulationPredicate = {
|
||||||
iterationFilter: (idContentInfo) => {
|
iterationFilter: (idContentInfo) => {
|
||||||
|
|
||||||
// For unique ids. If also filtering on noteID, then only this file will be looked at,
|
switch (idContentInfo.scope) {
|
||||||
// while the other side is in another file and thus won't be found.
|
case IDScope.Unique:
|
||||||
if (idContentInfo.scope == IDScope.UNIQUE)
|
// For unique ids. If also filtering on noteID, then only this file will be looked at,
|
||||||
return idContentInfo.id.isCardEqual(id);
|
// while the other side is in another file and thus won't be found.
|
||||||
|
return idContentInfo.id.isCardEqual(id);
|
||||||
|
|
||||||
// Should work for file-scoped IDs.
|
case IDScope.Note:
|
||||||
if (idContentInfo.scope == IDScope.NOTE)
|
// Should work for file-scoped IDs.
|
||||||
return idContentInfo.id.isEqual(id, true);
|
return idContentInfo.id.isEqual(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
||||||
},
|
},
|
||||||
|
|
@ -142,11 +144,13 @@ export class ContentParser extends DeclarationParser {
|
||||||
if (!this.isComplete(maybeParsedCard))
|
if (!this.isComplete(maybeParsedCard))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (idContentInfo.scope == IDScope.UNIQUE)
|
switch (idContentInfo.scope) {
|
||||||
return maybeParsedCard.frontID.isCardEqual(id);
|
case IDScope.Unique:
|
||||||
|
return maybeParsedCard.frontID.isCardEqual(id);
|
||||||
|
|
||||||
if (idContentInfo.scope == IDScope.NOTE)
|
case IDScope.Note:
|
||||||
return maybeParsedCard.frontID.isEqual(id, true);
|
return maybeParsedCard.frontID.isEqual(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +273,7 @@ export class ContentParser extends DeclarationParser {
|
||||||
|
|
||||||
cardInfos.push({
|
cardInfos.push({
|
||||||
id: id,
|
id: id,
|
||||||
scope: IDScope.NOTE,
|
scope: IDScope.Note,
|
||||||
contentInfo: {
|
contentInfo: {
|
||||||
range: {
|
range: {
|
||||||
start: currentHeading,
|
start: currentHeading,
|
||||||
|
|
@ -287,8 +291,8 @@ export class ContentParser extends DeclarationParser {
|
||||||
|
|
||||||
// Look for a declaration in the frontmatter
|
// Look for a declaration in the frontmatter
|
||||||
if (cache.frontmatter) {
|
if (cache.frontmatter) {
|
||||||
for (const key of Declaration.supportedFrontmatterKeys) {
|
for (const key of DeclarationConstants.Frontmatter.KEYS) {
|
||||||
const declaration = CardDeclarationAssistant.fromFrontmatter(cache.frontmatter[key]);
|
const declaration = ContentParser.declarationFromFrontmatter(cache.frontmatter[key]);
|
||||||
if (declaration) {
|
if (declaration) {
|
||||||
cardInfos.push({
|
cardInfos.push({
|
||||||
id: fullIDFromDeclaration(declaration, noteID),
|
id: fullIDFromDeclaration(declaration, noteID),
|
||||||
|
|
@ -306,7 +310,7 @@ export class ContentParser extends DeclarationParser {
|
||||||
|
|
||||||
for (const section of cache.sections ?? []) {
|
for (const section of cache.sections ?? []) {
|
||||||
|
|
||||||
const declaration = this.getDeclarationFromSection(section, noteID, fileContent);
|
const declaration = ContentParser.getDeclarationFromSection(section, noteID, fileContent);
|
||||||
if (!declaration)
|
if (!declaration)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { isString } from "TypeAssistant";
|
||||||
import { PLUGIN_NAME } from "ui/constants";
|
import { PLUGIN_NAME } from "ui/constants";
|
||||||
|
|
||||||
export interface PluginSettings {
|
export interface PluginSettings {
|
||||||
|
/** Trimmed. */
|
||||||
uiPrefix: string;
|
uiPrefix: string;
|
||||||
hideCardSectionMarker: boolean;
|
hideCardSectionMarker: boolean;
|
||||||
hideDeclarationInReadingView: boolean;
|
hideDeclarationInReadingView: boolean;
|
||||||
|
|
@ -47,7 +48,7 @@ const DEFAULT_SCHEDULER: FsrsScheduler = {
|
||||||
|
|
||||||
export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void;
|
export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void;
|
||||||
|
|
||||||
export type SettingsChangedInfo = "schedulerConfig"; // | "x"
|
export type SettingsChangedInfo = "schedulerConfig";// | typeof UNARY_UNION_DEFAULT;
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ export class SettingsManager {
|
||||||
public settings: PluginSettings;
|
public settings: PluginSettings;
|
||||||
|
|
||||||
/** Saves the {@link settings} to disk. */
|
/** Saves the {@link settings} to disk. */
|
||||||
public save: (changedInfo?: SettingsChangedInfo) => Promise<void>;
|
public readonly save: (changedInfo?: SettingsChangedInfo) => Promise<void>;
|
||||||
|
|
||||||
public static readonly DEFAULT_DATA: PluginSettings = {
|
public static readonly DEFAULT_DATA: PluginSettings = {
|
||||||
uiPrefix: PLUGIN_NAME,
|
uiPrefix: PLUGIN_NAME,
|
||||||
|
|
@ -68,11 +69,14 @@ export class SettingsManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise<void>, onSaved: (changedInfo?: SettingsChangedInfo) => void) {
|
public constructor(
|
||||||
|
settings: PluginSettings,
|
||||||
|
save: (settings: PluginSettings) => Promise<void>,
|
||||||
|
onSaved: (changedInfo?: SettingsChangedInfo) => void) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.save = async (changedInfo?: SettingsChangedInfo) => {
|
this.save = async (changedInfo?: SettingsChangedInfo) => {
|
||||||
await save(this.settings);
|
await save(this.settings);
|
||||||
onSaved?.(changedInfo);
|
onSaved(changedInfo);
|
||||||
this.notifyOnChangedListeners(false);
|
this.notifyOnChangedListeners(false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { DeckableFullID, FullID, NoteID } from "data/FullID";
|
import { DeckableFullID, FullID, NoteID } from "#/data/FullID";
|
||||||
import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration";
|
import { CardDeclarable, ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { Num, Str } from "#/utils/ts";
|
||||||
import { TFile } from "obsidian";
|
import { TFile } from "obsidian";
|
||||||
import { Num, Obj, Str } from "utils/ts";
|
|
||||||
|
|
||||||
export function asNoteID(value: TFile | string): NoteID {
|
export function asNoteID(value: TFile | string): NoteID {
|
||||||
if (value instanceof TFile)
|
if (value instanceof TFile)
|
||||||
|
|
@ -13,18 +13,8 @@ export function asNoteID(value: TFile | string): NoteID {
|
||||||
|
|
||||||
export function fullIDFromDeclaration(declaration: CardDeclarable, noteID: NoteID): DeckableFullID | FullID {
|
export function fullIDFromDeclaration(declaration: CardDeclarable, noteID: NoteID): DeckableFullID | FullID {
|
||||||
return declaration.deckID
|
return declaration.deckID
|
||||||
? new DeckableFullID(noteID, declaration.id, CardDeclarationAssistant.isFrontSide(declaration, true), [declaration.deckID])
|
? new DeckableFullID(noteID, declaration.id, ExplicitDeclarationAssistant.isFrontSide(declaration, true), [declaration.deckID])
|
||||||
: FullID.create(noteID, declaration.id, CardDeclarationAssistant.isFrontSide(declaration, true));
|
: FullID.create(noteID, declaration.id, ExplicitDeclarationAssistant.isFrontSide(declaration, true));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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`.
|
|
||||||
*/
|
|
||||||
export function isObject(value: unknown): value is object {
|
|
||||||
return Obj.is(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isString(value: unknown): value is string {
|
export function isString(value: unknown): value is string {
|
||||||
|
|
|
||||||
70
src/commands/editor.ts
Normal file
70
src/commands/editor.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { UniqueID } from "#/data/UniqueID";
|
||||||
|
import t from "#/Localization";
|
||||||
|
import { Command, Editor, MarkdownFileInfo, MarkdownView } from "obsidian";
|
||||||
|
import { Str } from "utils/ts";
|
||||||
|
|
||||||
|
export const EditorCommand = {
|
||||||
|
|
||||||
|
generateId: (): Command => ({
|
||||||
|
id: "generate-id-cursor",
|
||||||
|
name: t.commands.generateId.name,
|
||||||
|
editorCallback: (editor: Editor, _ctx) => {
|
||||||
|
editor.replaceRange(UniqueID.generateID(), editor.getCursor())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
insertReviewUnit: (copyLastPage: boolean): Command => ({
|
||||||
|
id: "insert-review-unit" + (copyLastPage ? "-copy-last-page" : ""),
|
||||||
|
name: "Insert empty declaration under cursor " + (copyLastPage ? "(put answer side in clipboard)" : "(both sides)"),
|
||||||
|
editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
|
||||||
|
|
||||||
|
const file = ctx.file;
|
||||||
|
if (file === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const cursor = editor.getCursor();
|
||||||
|
const fileCache = ctx.app.metadataCache.getFileCache(file);
|
||||||
|
const headings = fileCache?.headings || [];
|
||||||
|
|
||||||
|
let headingLevel = 0;
|
||||||
|
for (let i = headings.length - 1; i >= 0; i--) {
|
||||||
|
if (headings[i]!.position.start.line <= cursor.line) {
|
||||||
|
headingLevel = headings[i]!.level;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headingLevel = Math.max(headingLevel + 1, 2);
|
||||||
|
const hashes = '#'.repeat(headingLevel || 2);
|
||||||
|
|
||||||
|
const id = UniqueID.generateID();
|
||||||
|
|
||||||
|
const createCardPage = (isBack: boolean) => {
|
||||||
|
const title = isBack ? "Answer heading" : "Question heading";
|
||||||
|
const side = isBack ? "b" : "f";
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${hashes} ${title}`,
|
||||||
|
Str.LF,
|
||||||
|
"```ct",
|
||||||
|
"id: " + id,
|
||||||
|
"side: " + side,
|
||||||
|
"```",
|
||||||
|
Str.LF,
|
||||||
|
"*Add content here*",
|
||||||
|
Str.LF,
|
||||||
|
].join(Str.LF);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstPage = createCardPage(false);
|
||||||
|
const lastPage = createCardPage(true);
|
||||||
|
const codeBlock = copyLastPage ? firstPage : firstPage.concat(lastPage);
|
||||||
|
|
||||||
|
editor.replaceRange(codeBlock, cursor);
|
||||||
|
editor.setCursor(editor.offsetToPos(editor.posToOffset(cursor) + firstPage.length - 2));
|
||||||
|
|
||||||
|
if (copyLastPage)
|
||||||
|
navigator.clipboard.writeText(lastPage);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,6 @@ import { DeclarationParser } from "declarations/DeclarationParser";
|
||||||
import t from "Localization";
|
import t from "Localization";
|
||||||
import { SelectDeckModal } from "modals/SelectDeckModal";
|
import { SelectDeckModal } from "modals/SelectDeckModal";
|
||||||
import { App, Command, Keymap, MarkdownView, PaneType, TFile } from "obsidian";
|
import { App, Command, Keymap, MarkdownView, PaneType, TFile } from "obsidian";
|
||||||
import { UIAssistant } from "ui/UIAssistant";
|
|
||||||
import { DecksView } from "views/DecksView";
|
import { DecksView } from "views/DecksView";
|
||||||
import { DefinedContentView } from "views/DefinedContentView";
|
import { DefinedContentView } from "views/DefinedContentView";
|
||||||
import { ReviewView } from "views/review/ReviewView";
|
import { ReviewView } from "views/review/ReviewView";
|
||||||
|
|
@ -15,6 +14,7 @@ export const OpenView = {
|
||||||
const leaf = app.workspace.getLeaf(paneType);
|
const leaf = app.workspace.getLeaf(paneType);
|
||||||
await leaf.setViewState({
|
await leaf.setViewState({
|
||||||
type: DecksView.TYPE,
|
type: DecksView.TYPE,
|
||||||
|
state: DecksView.createViewState(),
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -45,21 +45,24 @@ export const OpenView = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const allDecks = dataStore.getAllDecks();
|
const allDecks = dataStore.getAllDecks();
|
||||||
if (allDecks.length) {
|
if (allDecks.length > 0) {
|
||||||
const modal = new SelectDeckModal(
|
const modal = new SelectDeckModal(
|
||||||
app,
|
app,
|
||||||
dataStore,
|
dataStore,
|
||||||
[...[UIAssistant.allDecksOptionItem()], ...allDecks],
|
allDecks,
|
||||||
async (deck, evt) => {
|
(deck, evt) => {
|
||||||
await openView(ReviewView.createViewState(deck.id === UIAssistant.DECK_ID_NONE ? null : deck.id), Keymap.isModEvent(evt));
|
openView(
|
||||||
|
ReviewView.createViewState(deck === null ? null : deck.id),
|
||||||
|
Keymap.isModEvent(evt)
|
||||||
|
).catch(console.error);
|
||||||
});
|
});
|
||||||
modal.setPlaceholder(t.modals.selectDeck.placeholder);
|
modal.setPlaceholder(t.modals.selectDeck.placeholder);
|
||||||
modal.open();
|
modal.open();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await openView(undefined, paneType);
|
await openView(ReviewView.createViewState(null), paneType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OpenViewCommand = {
|
export const OpenViewCommand = {
|
||||||
|
|
|
||||||
14
src/constants.ts
Normal file
14
src/constants.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { CssClass as ObsCssClass } from "utils/obs/constants";
|
||||||
|
|
||||||
|
const CSS_PREFIX = "come-through-";
|
||||||
|
|
||||||
|
export const CssClass = {
|
||||||
|
PREFIX: CSS_PREFIX,
|
||||||
|
View: {
|
||||||
|
WORKSPACE_LEAF_CONTENT_MODIFIER: CSS_PREFIX + ObsCssClass.Workspace.LEAF_CONTENT_MODIFIER,
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
Modal: {
|
||||||
|
CONTENT: CSS_PREFIX + "modal-content",
|
||||||
|
} as const,
|
||||||
|
} as const;
|
||||||
|
|
@ -5,7 +5,7 @@ import { deepEqual, strictDeepEqual } from 'fast-equals';
|
||||||
import { asNoteID, isDate, isString } from "TypeAssistant";
|
import { asNoteID, isDate, isString } from "TypeAssistant";
|
||||||
import { DateTime } from "utils/datetime";
|
import { DateTime } from "utils/datetime";
|
||||||
import { UnexpectedUndefinedError } from "utils/errors";
|
import { UnexpectedUndefinedError } from "utils/errors";
|
||||||
import { Obj, Str } from "utils/ts";
|
import { Arr, Obj, Str } from "utils/ts";
|
||||||
|
|
||||||
export interface DataStoreRoot {
|
export interface DataStoreRoot {
|
||||||
decks: DecksData;
|
decks: DecksData;
|
||||||
|
|
@ -15,7 +15,7 @@ export interface DataStoreRoot {
|
||||||
|
|
||||||
type DecksData = Record<DeckID, DeckData>;
|
type DecksData = Record<DeckID, DeckData>;
|
||||||
|
|
||||||
interface DeckData {
|
export interface DeckData {
|
||||||
/** Name of the deck */
|
/** Name of the deck */
|
||||||
n: string;
|
n: string;
|
||||||
/** Parent decks */
|
/** Parent decks */
|
||||||
|
|
@ -202,6 +202,10 @@ export class DeckEditor {
|
||||||
/** Use with {@link DataStore.registerOnChangedCallback} */
|
/** Use with {@link DataStore.registerOnChangedCallback} */
|
||||||
export type DataChanged = (data: DataStoreRoot) => void;
|
export type DataChanged = (data: DataStoreRoot) => void;
|
||||||
|
|
||||||
|
type DataSection = Exclude<keyof DataStoreRoot, "decks"> | "all";
|
||||||
|
/** See {@link DataStore.Internal.dispatchSection}. */
|
||||||
|
type SectionActions<R> = { [K in DataSection]: () => R; };
|
||||||
|
|
||||||
export class DataStore {
|
export class DataStore {
|
||||||
|
|
||||||
public static readonly DEFAULT_DATA: DataStoreRoot = {
|
public static readonly DEFAULT_DATA: DataStoreRoot = {
|
||||||
|
|
@ -222,6 +226,8 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public cardInfo(id: FullID) {
|
public cardInfo(id: FullID) {
|
||||||
|
Env.log.data("DataStore:cardInfo: id", id);
|
||||||
|
|
||||||
let info = `${id.isFrontSide ? "front" : "back"} of ${id.cardID}`
|
let info = `${id.isFrontSide ? "front" : "back"} of ${id.cardID}`
|
||||||
|
|
||||||
const card = this.getCard(id);
|
const card = this.getCard(id);
|
||||||
|
|
@ -234,7 +240,7 @@ export class DataStore {
|
||||||
const decks: DeckData[] = [];
|
const decks: DeckData[] = [];
|
||||||
for (const deckID of card.d) {
|
for (const deckID of card.d) {
|
||||||
const deckData = this.getDeck(deckID);
|
const deckData = this.getDeck(deckID);
|
||||||
console.assert(deckData);
|
console.assert(deckData !== null);
|
||||||
if (deckData !== null)
|
if (deckData !== null)
|
||||||
decks.push(deckData);
|
decks.push(deckData);
|
||||||
}
|
}
|
||||||
|
|
@ -248,7 +254,7 @@ export class DataStore {
|
||||||
* @param cb Return value is ignored.
|
* @param cb Return value is ignored.
|
||||||
*/
|
*/
|
||||||
public createDeck(cb: (editor: DeckEditor) => unknown): DeckIDDataTuple {
|
public createDeck(cb: (editor: DeckEditor) => unknown): DeckIDDataTuple {
|
||||||
|
Env.log.data("DataStore:createDeck");
|
||||||
const editor = new DeckEditor(UniqueID.generateID(), {
|
const editor = new DeckEditor(UniqueID.generateID(), {
|
||||||
n: "",
|
n: "",
|
||||||
p: [],
|
p: [],
|
||||||
|
|
@ -261,18 +267,25 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public editCard(id: FullID, cb: (editor: CardEditor) => boolean) {
|
public editCard(id: FullID, cb: (editor: CardEditor) => boolean) {
|
||||||
|
Env.log.data("DataStore:editCard: id", id);
|
||||||
const data = this.getCard(id, true);
|
const data = this.getCard(id, true);
|
||||||
if (data && cb(new CardEditor(id, data)))
|
if (data && cb(new CardEditor(id, data)))
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public editDeck(id: DeckID, cb: (editor: DeckEditor) => boolean) {
|
/**
|
||||||
const data = this.getDeck(id, true);
|
* @returns `null` if {@link id} was not found and {@link cb} was not called. If the {@link id} was found, the {@link DeckData} is returned regardless of whether {@link cb} was called.
|
||||||
if (data && cb(new DeckEditor(id, data)))
|
*/
|
||||||
|
public editDeck(id: DeckID, cb: (editor: DeckEditor) => boolean, throwIfNotFound = false): DeckData | null {
|
||||||
|
Env.log.data("DataStore:editDeck: id", id);
|
||||||
|
const data = this.getDeck(id, throwIfNotFound);
|
||||||
|
if (data !== null && cb(new DeckEditor(id, data)))
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDeck(id: DeckID, throwIfNotFound = false): DeckData | null {
|
public getDeck(id: DeckID, throwIfNotFound = false): DeckData | null {
|
||||||
|
Env.log.data("DataStore:getDeck: id", id);
|
||||||
const data = this.data.decks[id] ?? null;
|
const data = this.data.decks[id] ?? null;
|
||||||
if (data === null && throwIfNotFound)
|
if (data === null && throwIfNotFound)
|
||||||
throw new Error(`Deck with ID "${id}" was not found.`);
|
throw new Error(`Deck with ID "${id}" was not found.`);
|
||||||
|
|
@ -280,6 +293,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteDeck(idToDelete: DeckID, moveChildrenToID?: DeckID, throwIfNotFound = false) {
|
public deleteDeck(idToDelete: DeckID, moveChildrenToID?: DeckID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:deleteDeck: idToDelete", idToDelete, "moveChildrenToID", moveChildrenToID);
|
||||||
const data = this.getDeck(idToDelete, throwIfNotFound);
|
const data = this.getDeck(idToDelete, throwIfNotFound);
|
||||||
if (!data)
|
if (!data)
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -312,7 +326,7 @@ export class DataStore {
|
||||||
const {
|
const {
|
||||||
predicate,
|
predicate,
|
||||||
} = options || {};
|
} = options || {};
|
||||||
|
Env.log.data("DataStore:getAllDecks: predicate", predicate);
|
||||||
const decks: DeckIDDataTuple[] = [];
|
const decks: DeckIDDataTuple[] = [];
|
||||||
|
|
||||||
for (const [id, data] of Object.entries(this.data.decks)) {
|
for (const [id, data] of Object.entries(this.data.decks)) {
|
||||||
|
|
@ -336,12 +350,13 @@ export class DataStore {
|
||||||
* @returns The removed {@link CardData}, or `null` if {@link id} was not found.
|
* @returns The removed {@link CardData}, or `null` if {@link id} was not found.
|
||||||
*/
|
*/
|
||||||
private deleteRemovedCard(id: FullID, throwIfNotFound = false) {
|
private deleteRemovedCard(id: FullID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:deleteRemovedCard: id", id);
|
||||||
const removedCard = this.getRemovedCard(id, throwIfNotFound);
|
const removedCard = this.getRemovedCard(id, throwIfNotFound);
|
||||||
if (!removedCard)
|
if (!removedCard)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const removedNoteData = this.getRemovedNote(id.noteID, throwIfNotFound);
|
const removedNoteData = this.getRemovedNote(id.noteID, throwIfNotFound);
|
||||||
Env.assert(removedNoteData);
|
Env.assert(removedNoteData !== null);
|
||||||
if (removedNoteData === null)
|
if (removedNoteData === null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|
@ -349,7 +364,7 @@ export class DataStore {
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
|
|
||||||
if (StatisticsHelper.isRemovedNoteEmpty(removedNoteData))
|
if (StatisticsHelper.isRemovedNoteEmpty(removedNoteData))
|
||||||
this.deleteRemovedNote(id.noteID, throwIfNotFound);
|
this.deleteNote("removed", id.noteID, throwIfNotFound);
|
||||||
|
|
||||||
return removedCard;
|
return removedCard;
|
||||||
}
|
}
|
||||||
|
|
@ -358,11 +373,12 @@ export class DataStore {
|
||||||
* @param removedBeforeDate Delete only items that were removed before this date. Set to `undefined` to delete all items.
|
* @param removedBeforeDate Delete only items that were removed before this date. Set to `undefined` to delete all items.
|
||||||
*/
|
*/
|
||||||
private deleteRemovedCards(removedBeforeDate?: Date) {
|
private deleteRemovedCards(removedBeforeDate?: Date) {
|
||||||
if (removedBeforeDate) {
|
Env.log.data("DataStore:deleteRemovedCards: removedBeforeDate", removedBeforeDate);
|
||||||
|
if (removedBeforeDate !== undefined) {
|
||||||
const time = removedBeforeDate.getTime();
|
const time = removedBeforeDate.getTime();
|
||||||
this.getAllRemovedCards(undefined, (_cardID: CardID, data: RemovedCardData) => {
|
this.getAllRemovedCards(undefined, (_cardID: CardID, data: RemovedCardData) => {
|
||||||
const date = StatisticsHelper.ensureDate(data.date);
|
const date = StatisticsHelper.ensureDate(data.date);
|
||||||
Env.dev?.assert(date, "Expected date parsable string.");
|
Env.dev?.assert(date !== null, "Expected date parsable string.");
|
||||||
return date && date.getTime() < time ? true : false;
|
return date && date.getTime() < time ? true : false;
|
||||||
}).forEach(tuple => this.deleteRemovedCard(tuple.id));
|
}).forEach(tuple => this.deleteRemovedCard(tuple.id));
|
||||||
}
|
}
|
||||||
|
|
@ -380,18 +396,31 @@ export class DataStore {
|
||||||
* @param throwIfExists If card already exist as active.
|
* @param throwIfExists If card already exist as active.
|
||||||
*/
|
*/
|
||||||
private ensureActiveCard(id: FullID, statisticsFactory: () => StatisticsData, throwIfExists = false) {
|
private ensureActiveCard(id: FullID, statisticsFactory: () => StatisticsData, throwIfExists = false) {
|
||||||
|
Env.log.data("DataStore:ensureActiveCard: id", id);
|
||||||
|
|
||||||
const removedCard = this.getAllRemovedCards(
|
// First check if already active in any note.
|
||||||
undefined, //(noteID, _) => id.hasNoteID(noteID),
|
// - Prevents dublicates, e.g., it the removal event occurs after the add event.
|
||||||
|
const existingActive = Arr.firstOrNull(this.getAllCards((cardID, _) => id.hasCardID(cardID)));
|
||||||
|
if (existingActive !== null) {
|
||||||
|
if (existingActive.id.hasNoteID(id.noteID)) {
|
||||||
|
if (throwIfExists)
|
||||||
|
throw new CardAlreadyExistsError(id, [existingActive.id]);
|
||||||
|
return existingActive.data;
|
||||||
|
}
|
||||||
|
return this.moveActiveCard(existingActive.id, id.noteID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check removed
|
||||||
|
const removed = Arr.firstOrNull(this.getAllRemovedCards(undefined,
|
||||||
(cardID, _) => id.hasCardID(cardID) // For unique IDs. They can be in different notes. Just match on the hash.
|
(cardID, _) => id.hasCardID(cardID) // For unique IDs. They can be in different notes. Just match on the hash.
|
||||||
).first();
|
));
|
||||||
|
|
||||||
this.createActiveNote(id, false);
|
this.createActiveNote(id, false);
|
||||||
let cardToAdd: CardIDDataTuple;
|
let cardToAdd: CardIDDataTuple;
|
||||||
|
|
||||||
if (removedCard) {
|
if (removed !== null) {
|
||||||
this.deleteRemovedCard(removedCard.id, true);
|
this.deleteRemovedCard(removed.id, true);
|
||||||
cardToAdd = StatisticsHelper.toCardIDDataTuple(id, StatisticsHelper.removedCardToCard(removedCard.data));
|
cardToAdd = StatisticsHelper.toCardIDDataTuple(id, StatisticsHelper.removedCardToCard(removed.data));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const deckIDs = id instanceof DeckableFullID ? id.deckIDs : [];
|
const deckIDs = id instanceof DeckableFullID ? id.deckIDs : [];
|
||||||
|
|
@ -410,7 +439,7 @@ export class DataStore {
|
||||||
* @returns The created card or `null` if already existed.
|
* @returns The created card or `null` if already existed.
|
||||||
*/
|
*/
|
||||||
private addAsActiveCard(card: CardIDDataTuple, throwIfExists = false) {
|
private addAsActiveCard(card: CardIDDataTuple, throwIfExists = false) {
|
||||||
|
Env.log.data("DataStore:addAsActiveCard: card", card);
|
||||||
if (this.getCard(card.id, false) !== null) {
|
if (this.getCard(card.id, false) !== null) {
|
||||||
if (throwIfExists)
|
if (throwIfExists)
|
||||||
throw new CardAlreadyExistsError(card.id, []);
|
throw new CardAlreadyExistsError(card.id, []);
|
||||||
|
|
@ -432,6 +461,7 @@ export class DataStore {
|
||||||
* @returns Returns the note, whether it was created or not.
|
* @returns Returns the note, whether it was created or not.
|
||||||
*/
|
*/
|
||||||
private ensureActiveNote(id: FullID) {
|
private ensureActiveNote(id: FullID) {
|
||||||
|
Env.log.data("DataStore:ensureActiveNote: id", id);
|
||||||
return this.createActiveNote(id, false) ?? this.getNote(id.noteID, true)!;
|
return this.createActiveNote(id, false) ?? this.getNote(id.noteID, true)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,6 +469,7 @@ export class DataStore {
|
||||||
* Returns existing {@link RemovedNoteData} from {@link DataStoreRoot.removed} or creates and returns a new one if not found.
|
* Returns existing {@link RemovedNoteData} from {@link DataStoreRoot.removed} or creates and returns a new one if not found.
|
||||||
*/
|
*/
|
||||||
private ensureRemovedNote(noteID: NoteID) {
|
private ensureRemovedNote(noteID: NoteID) {
|
||||||
|
Env.log.data("DataStore:ensureRemovedNote: noteID", noteID);
|
||||||
let note = this.getRemovedNote(noteID);
|
let note = this.getRemovedNote(noteID);
|
||||||
if (!note) {
|
if (!note) {
|
||||||
note = StatisticsHelper.createRemovedNoteData();
|
note = StatisticsHelper.createRemovedNoteData();
|
||||||
|
|
@ -454,6 +485,7 @@ export class DataStore {
|
||||||
* @returns The created note or `null` if already existed.
|
* @returns The created note or `null` if already existed.
|
||||||
*/
|
*/
|
||||||
private createActiveNote(id: FullID, throwIfExists = false): NoteData | null {
|
private createActiveNote(id: FullID, throwIfExists = false): NoteData | null {
|
||||||
|
Env.log.data("DataStore:createActiveNote: id", id);
|
||||||
id.throwIfNoNoteID()
|
id.throwIfNoNoteID()
|
||||||
|
|
||||||
if (this.getNote(id.noteID, false)) {
|
if (this.getNote(id.noteID, false)) {
|
||||||
|
|
@ -471,30 +503,55 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeNote(noteID: NoteID) {
|
public removeNote(noteID: NoteID) {
|
||||||
|
Env.log.data("DataStore:removeNote: noteID", noteID);
|
||||||
return this.moveActiveNoteToRemoved(noteID);
|
return this.moveActiveNoteToRemoved(noteID);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeAllCards() {
|
public async removeAllCards() {
|
||||||
|
Env.log.data("DataStore:removeAllCards");
|
||||||
for (const [noteID, note] of Object.entries(this.data.active)) {
|
for (const [noteID, note] of Object.entries(this.data.active)) {
|
||||||
for (const cardID of Object.keys(note.cs))
|
for (const cardID of Object.keys(note.cs))
|
||||||
this.moveActiveCardToRemoved(StatisticsHelper.createFullID(noteID, cardID));
|
this.moveActiveCardToRemoved(StatisticsHelper.createFullID(noteID, cardID));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveActiveCardToRemoved(id: FullID, throwIfNotFound = false) {
|
/**
|
||||||
|
* Move an active {@link CardData} to another note.
|
||||||
|
* @param id Item to move.
|
||||||
|
* @param toNoteID Target to move to.
|
||||||
|
* @param throwIfNotFound If set to `true`, throws an error if the card is not found in {@link id} or already exists in {@link toNoteID}.
|
||||||
|
* @returns `null` if item cannot be found or already exists in {@link toNoteID}.
|
||||||
|
*/
|
||||||
|
private moveActiveCard(id: FullID, toNoteID: NoteID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:moveActiveCard: id", id, "toNoteID", toNoteID);
|
||||||
|
|
||||||
const card = this.deleteActiveCard(id, throwIfNotFound);
|
const card = this.deleteActiveCard(id, throwIfNotFound);
|
||||||
if (!card)
|
if (card === null)
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
|
const newID = FullID.create(toNoteID, id.cardIDOrThrow(), id.isFrontSide);
|
||||||
|
return this.addAsActiveCard({ id: newID, data: card }, throwIfNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveActiveCardToRemoved(id: FullID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:moveActiveCardToRemoved: id", id);
|
||||||
|
const card = this.deleteActiveCard(id, throwIfNotFound);
|
||||||
|
if (card === null)
|
||||||
|
return null;
|
||||||
|
|
||||||
const removedNote = this.ensureRemovedNote(id.noteID);
|
const removedNote = this.ensureRemovedNote(id.noteID);
|
||||||
removedNote.cs[id.cardIDOrThrow()] = StatisticsHelper.cardToRemovedCard(card);
|
const removedItem = StatisticsHelper.cardToRemovedCard(card);
|
||||||
|
|
||||||
|
removedNote.cs[id.cardIDOrThrow()] = removedItem;
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
return true;
|
|
||||||
|
return removedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveActiveNoteToRemoved(noteID: NoteID, throwIfNotFound = false) {
|
private moveActiveNoteToRemoved(noteID: NoteID, throwIfNotFound = false) {
|
||||||
const note = this.deleteActiveNote(noteID, throwIfNotFound);
|
Env.log.data("DataStore:moveActiveNoteToRemoved: noteID", noteID);
|
||||||
if (!note)
|
const note = this.deleteNote("active", noteID, throwIfNotFound);
|
||||||
|
if (note === null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
this.data.removed[noteID] = StatisticsHelper.noteToRemovedNote(note);
|
this.data.removed[noteID] = StatisticsHelper.noteToRemovedNote(note);
|
||||||
|
|
@ -509,6 +566,7 @@ export class DataStore {
|
||||||
* @returns The removed {@link CardData}, or `null` if {@link id} was not found.
|
* @returns The removed {@link CardData}, or `null` if {@link id} was not found.
|
||||||
*/
|
*/
|
||||||
private deleteActiveCard(id: FullID, throwIfNotFound = false) {
|
private deleteActiveCard(id: FullID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:deleteActiveCard: id", id);
|
||||||
id.throwIfNoCardID();
|
id.throwIfNoCardID();
|
||||||
|
|
||||||
const note = this.getNote(id.noteID, throwIfNotFound);
|
const note = this.getNote(id.noteID, throwIfNotFound);
|
||||||
|
|
@ -521,41 +579,50 @@ export class DataStore {
|
||||||
|
|
||||||
delete note.cs[id.cardID];
|
delete note.cs[id.cardID];
|
||||||
if (StatisticsHelper.isNoteEmpty(note))
|
if (StatisticsHelper.isNoteEmpty(note))
|
||||||
this.deleteActiveNote(id.noteID);
|
this.deleteNote("active", id.noteID);
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes {@link NoteData} with {@link noteID} from {@link DataStoreRoot.active} and returns it.
|
* Deletes {@link NoteData} with {@link noteID} from {@link section} and returns it.
|
||||||
* @param noteID
|
* @param section
|
||||||
* @returns The removed {@link NoteData}, or `null` if {@link noteID} was not found.
|
* @param noteID
|
||||||
*/
|
* @returns The removed {@link NoteData}, or `null` if {@link noteID} was not found.
|
||||||
private deleteActiveNote(noteID: NoteID, throwIfNotFound = false) {
|
*/
|
||||||
const note = this.getNote(noteID, throwIfNotFound);
|
private deleteNote(section: DataSection, noteID: NoteID, throwIfNotFound = false): NoteData | null {
|
||||||
if (note !== null) {
|
Env.log.data(`DataStore:deleteNote: section: ${section}, noteID: ${noteID}`);
|
||||||
delete this.data.active[noteID];
|
|
||||||
this.setDataDirty();
|
|
||||||
}
|
|
||||||
return note;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const actions: SectionActions<NoteData | null> = {
|
||||||
* Deletes {@link NoteData} with {@link noteID} from {@link DataStoreRoot.removed} and returns it.
|
active: () => {
|
||||||
* @param noteID
|
const n = this.getNote(noteID, section === "active" && throwIfNotFound); // Because `all` calls `active` first.
|
||||||
* @returns The removed {@link NoteData}, or `null` if {@link noteID} was not found.
|
if (n !== null)
|
||||||
*/
|
delete this.data.active[noteID];
|
||||||
private deleteRemovedNote(noteID: NoteID, throwIfNotFound = false) {
|
return n;
|
||||||
const note = this.getRemovedNote(noteID, throwIfNotFound);
|
},
|
||||||
if (note !== null) {
|
removed: () => {
|
||||||
delete this.data.removed[noteID];
|
const n = this.getRemovedNote(noteID, throwIfNotFound);
|
||||||
|
if (n !== null)
|
||||||
|
delete this.data.removed[noteID];
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
all: () => {
|
||||||
|
const note = actions.active();
|
||||||
|
return note !== null ? note : actions.removed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const note = DataStore.Internal.dispatchSection(section, actions);
|
||||||
|
|
||||||
|
if (note !== null)
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
}
|
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCard(id: FullID, throwIfNotFound = false): CardData | null {
|
public getCard(id: FullID, throwIfNotFound = false): CardData | null {
|
||||||
|
Env.log.data("DataStore:getCard: id", id);
|
||||||
id.throwIfNoNoteID();
|
id.throwIfNoNoteID();
|
||||||
id.throwIfNoCardID();
|
id.throwIfNoCardID();
|
||||||
|
|
||||||
|
|
@ -572,6 +639,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getNote(noteID: NoteID, throwIfNotFound = false): NoteData | null {
|
public getNote(noteID: NoteID, throwIfNotFound = false): NoteData | null {
|
||||||
|
Env.log.data("DataStore:getNote: noteID", noteID);
|
||||||
const note = this.data.active[noteID] ?? null;
|
const note = this.data.active[noteID] ?? null;
|
||||||
if (note === null && throwIfNotFound)
|
if (note === null && throwIfNotFound)
|
||||||
throw new Error(`Note with ID "${noteID}" was not found.`);
|
throw new Error(`Note with ID "${noteID}" was not found.`);
|
||||||
|
|
@ -588,6 +656,7 @@ export class DataStore {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
private getRemovedNote(noteID: NoteID, throwIfNotFound = false): RemovedNoteData | null {
|
private getRemovedNote(noteID: NoteID, throwIfNotFound = false): RemovedNoteData | null {
|
||||||
|
Env.log.data("DataStore:getRemovedNote: noteID", noteID);
|
||||||
const note = this.data.removed[noteID] ?? null;
|
const note = this.data.removed[noteID] ?? null;
|
||||||
if (note === null && throwIfNotFound)
|
if (note === null && throwIfNotFound)
|
||||||
throw new Error(`Removed note with ID "${noteID}" was not found.`);
|
throw new Error(`Removed note with ID "${noteID}" was not found.`);
|
||||||
|
|
@ -595,6 +664,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRemovedCard(id: FullID, throwIfNotFound = false): RemovedCardData | null {
|
private getRemovedCard(id: FullID, throwIfNotFound = false): RemovedCardData | null {
|
||||||
|
Env.log.data("DataStore:getRemovedCard: id", id);
|
||||||
const note = this.getRemovedNote(id.noteID, throwIfNotFound);
|
const note = this.getRemovedNote(id.noteID, throwIfNotFound);
|
||||||
if (!note)
|
if (!note)
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -611,8 +681,8 @@ export class DataStore {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public getAllCardsForDeck(deckID?: DeckID): CardIDDataTuple[] {
|
public getAllCardsForDeck(deckID?: DeckID): CardIDDataTuple[] {
|
||||||
Env.log.d("DataStore:getAllCardsForDeck:deckID", deckID);
|
Env.log.data("DataStore:getAllCardsForDeck: deckID", deckID);
|
||||||
Env.dev?.assert(deckID === undefined || isString(deckID) && deckID !== Env.str.EMPTY, deckID);
|
Env.dev?.assert(deckID === undefined || isString(deckID) && deckID !== Str.EMPTY, deckID);
|
||||||
if (deckID === undefined)
|
if (deckID === undefined)
|
||||||
return this.getAllCards();
|
return this.getAllCards();
|
||||||
|
|
||||||
|
|
@ -629,6 +699,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private descendantDecks(parentID?: DeckID): DeckIDDataTuple[] {
|
private descendantDecks(parentID?: DeckID): DeckIDDataTuple[] {
|
||||||
|
Env.log.data("DataStore:descendantDecks: parentID", parentID);
|
||||||
if (parentID === undefined)
|
if (parentID === undefined)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
|
|
@ -645,13 +716,14 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAllCards(cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
public getAllCards(cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
||||||
|
Env.log.data("DataStore:getAllCards");
|
||||||
return this.getAllCardsWithFilters(undefined, cardFilter);
|
return this.getAllCardsWithFilters(undefined, cardFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAllCardsWithFilters(
|
private getAllCardsWithFilters(
|
||||||
noteFilter?: (noteID: NoteID, data: NoteData) => boolean,
|
noteFilter?: (noteID: NoteID, data: NoteData) => boolean,
|
||||||
cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
||||||
|
Env.log.data("DataStore:getAllCardsWithFilters");
|
||||||
const cards: CardIDDataTuple[] = [];
|
const cards: CardIDDataTuple[] = [];
|
||||||
|
|
||||||
for (const [noteID, note] of Object.entries(this.data.active)) {
|
for (const [noteID, note] of Object.entries(this.data.active)) {
|
||||||
|
|
@ -668,6 +740,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAllNotes(noteFilter?: (noteID: NoteID, data: NoteData) => boolean): NoteID[] {
|
public getAllNotes(noteFilter?: (noteID: NoteID, data: NoteData) => boolean): NoteID[] {
|
||||||
|
Env.log.data("DataStore:getAllNotes");
|
||||||
if (!noteFilter)
|
if (!noteFilter)
|
||||||
return Object.keys(this.data.active).map(k => asNoteID(k));
|
return Object.keys(this.data.active).map(k => asNoteID(k));
|
||||||
throw new Error("Not Implemented");
|
throw new Error("Not Implemented");
|
||||||
|
|
@ -676,7 +749,7 @@ export class DataStore {
|
||||||
private getAllRemovedCards(
|
private getAllRemovedCards(
|
||||||
noteFilter?: (noteID: NoteID, data: RemovedNoteData) => boolean,
|
noteFilter?: (noteID: NoteID, data: RemovedNoteData) => boolean,
|
||||||
cardFilter?: (cardID: CardID, data: RemovedCardData) => boolean) {
|
cardFilter?: (cardID: CardID, data: RemovedCardData) => boolean) {
|
||||||
|
Env.log.data("DataStore:getAllRemovedCards");
|
||||||
const cards: RemovedCardIDDataTuple[] = [];
|
const cards: RemovedCardIDDataTuple[] = [];
|
||||||
|
|
||||||
for (const [noteID, removedData] of Object.entries(this.data.removed)) {
|
for (const [noteID, removedData] of Object.entries(this.data.removed)) {
|
||||||
|
|
@ -720,11 +793,11 @@ export class DataStore {
|
||||||
* @returns `true` if the ID was changed successfully.
|
* @returns `true` if the ID was changed successfully.
|
||||||
*/
|
*/
|
||||||
public changeNoteID(oldID: NoteID, newID: NoteID, throwIfNotFound = false) {
|
public changeNoteID(oldID: NoteID, newID: NoteID, throwIfNotFound = false) {
|
||||||
|
Env.log.data("DataStore:changeNoteID: oldID", oldID, "newID", newID);
|
||||||
if (this.getNote(newID))
|
if (this.getNote(newID))
|
||||||
throw new Error(`Cannot overwrite ${newID}.`)
|
throw new Error(`Cannot overwrite ${newID}.`)
|
||||||
|
|
||||||
const deletedNote = this.deleteActiveNote(oldID, throwIfNotFound);
|
const deletedNote = this.deleteNote("active", oldID, throwIfNotFound);
|
||||||
if (deletedNote) {
|
if (deletedNote) {
|
||||||
this.data.active[newID] = deletedNote;
|
this.data.active[newID] = deletedNote;
|
||||||
this.setDataDirty();
|
this.setDataDirty();
|
||||||
|
|
@ -817,7 +890,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save() {
|
public async save() {
|
||||||
Env.log.d(`DataStore:save: dirty: ${this._isDataDirty}`);
|
Env.log.d("DataStore:save: dirty: ", this._isDataDirty);
|
||||||
if (this._isDataDirty) {
|
if (this._isDataDirty) {
|
||||||
const purgeRemovedBeforeDate = new Date((new Date()).getTime() - (this.purgeThreshold * 1000));
|
const purgeRemovedBeforeDate = new Date((new Date()).getTime() - (this.purgeThreshold * 1000));
|
||||||
this.deleteRemovedCards(purgeRemovedBeforeDate);
|
this.deleteRemovedCards(purgeRemovedBeforeDate);
|
||||||
|
|
@ -890,6 +963,7 @@ export class DataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerDataChanged() {
|
private triggerDataChanged() {
|
||||||
|
Env.log.data("DataStore:triggerDataChanged", this.registeredChangedCallbacks.length);
|
||||||
this.registeredChangedCallbacks.forEach(callback => {
|
this.registeredChangedCallbacks.forEach(callback => {
|
||||||
try {
|
try {
|
||||||
callback(this.data);
|
callback(this.data);
|
||||||
|
|
@ -902,6 +976,23 @@ export class DataStore {
|
||||||
|
|
||||||
private registeredChangedCallbacks: DataChanged[] = [];
|
private registeredChangedCallbacks: DataChanged[] = [];
|
||||||
|
|
||||||
|
private static readonly Internal = {
|
||||||
|
dispatchSection: function <R>(section: DataSection, actions: SectionActions<R>): R {
|
||||||
|
switch (section) {
|
||||||
|
case "active":
|
||||||
|
return actions.active();
|
||||||
|
case "removed":
|
||||||
|
return actions.removed();
|
||||||
|
case "all":
|
||||||
|
return actions.all();
|
||||||
|
default: {
|
||||||
|
const _exhaustiveCheck: never = section;
|
||||||
|
throw new Error(`DataStore: Unhandled section: ${_exhaustiveCheck}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public readonly filter = {
|
public readonly filter = {
|
||||||
cardsWithoutDeck: (card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(undefined)(card.id, card.data),
|
cardsWithoutDeck: (card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(undefined)(card.id, card.data),
|
||||||
cardsInDeck: (deckId: DeckID, card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(deckId)(card.id, card.data),
|
cardsInDeck: (deckId: DeckID, card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(deckId)(card.id, card.data),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Env } from "env";
|
||||||
|
|
||||||
export type CardID = string;
|
export type CardID = string;
|
||||||
export type NoteID = string;
|
export type NoteID = string;
|
||||||
export type IDFilter = (id: FullID) => boolean;
|
export type IDFilter = (id: FullID) => boolean;
|
||||||
|
|
@ -136,7 +138,7 @@ export class FullID implements FullID {
|
||||||
* @throws `Error` if {@link cardSide} is not set.
|
* @throws `Error` if {@link cardSide} is not set.
|
||||||
*/
|
*/
|
||||||
public get isFrontSide() {
|
public get isFrontSide() {
|
||||||
console.assert(this.cardSide);
|
Env.assert(this.cardSide !== undefined);
|
||||||
if (!this.cardSide)
|
if (!this.cardSide)
|
||||||
throw new Error(`Side not specified on id: ${this.toString()}`);
|
throw new Error(`Side not specified on id: ${this.toString()}`);
|
||||||
return this.cardSide === "f" || this.cardSide === "front";
|
return this.cardSide === "f" || this.cardSide === "front";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { DataStore, StatisticsData } from "data/DataStore";
|
import { DataStore, StatisticsData } from "#/data/DataStore";
|
||||||
import { FullID, NoteID } from "data/FullID";
|
import { FullID, NoteID } from "#/data/FullID";
|
||||||
import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration";
|
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
|
||||||
import { DeclarationInfo, DeclarationParser, PostParseInfo } from "declarations/DeclarationParser";
|
import { DeclarationInfo, DeclarationParser, PostParseInfo } from "#/declarations/DeclarationParser";
|
||||||
import { Env } from "env";
|
import { CardDeclarable, ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { Env } from "#/env";
|
||||||
|
import { asNoteID, isString } from "#/TypeAssistant";
|
||||||
|
import { UNARY_UNION_SUPPRESS } from "#/utils/ts";
|
||||||
import { App, CachedMetadata, Editor, FileManager, TAbstractFile, TFile } from "obsidian";
|
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. */
|
/** Keeps the plugin's internal data synchronized with card declarations in the vault by listening for and processing file system events. */
|
||||||
export class SyncManager {
|
export class SyncManager {
|
||||||
|
|
@ -44,7 +46,7 @@ export class SyncManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async open(file: TFile | null) {
|
public open = async (file: TFile | null) => {
|
||||||
Env.log.d(`SyncManager:open isSuspended: ${this.isSuspended}`);
|
Env.log.d(`SyncManager:open isSuspended: ${this.isSuspended}`);
|
||||||
if (this.isSuspended)
|
if (this.isSuspended)
|
||||||
return;
|
return;
|
||||||
|
|
@ -53,34 +55,34 @@ export class SyncManager {
|
||||||
const ids = await SyncManager.processFile(file, this.app);
|
const ids = await SyncManager.processFile(file, this.app);
|
||||||
await this.syncIDs(ids, file);
|
await this.syncIDs(ids, file);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
public async changed(file: TFile, data: string, cache: CachedMetadata) {
|
public changed = async (file: TFile, data: string, cache: CachedMetadata) => {
|
||||||
Env.log.d(`SyncManager:changed isSuspended: ${this.isSuspended}`);
|
Env.log.d(`SyncManager:changed isSuspended: ${this.isSuspended}`);
|
||||||
if (this.isSuspended)
|
if (this.isSuspended)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const ids = await SyncManager.processFileChanged(file, data, cache, this.app);
|
const ids = await SyncManager.processFileChanged(file, data, cache, this.app);
|
||||||
await this.syncIDs(ids, file);
|
await this.syncIDs(ids, file);
|
||||||
}
|
};
|
||||||
|
|
||||||
public async delete(file: TAbstractFile) {
|
public delete = async (file: TAbstractFile) => {
|
||||||
Env.log.d(`SyncManager:delete isSuspended: ${this.isSuspended}`);
|
Env.log.d(`SyncManager:delete isSuspended: ${this.isSuspended}`);
|
||||||
if (this.isSuspended)
|
if (this.isSuspended)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (file instanceof TFile && this.dataStore.removeNote(asNoteID(file.path)))
|
if (file instanceof TFile && this.dataStore.removeNote(asNoteID(file.path)))
|
||||||
await this.dataStore.save();
|
await this.dataStore.save();
|
||||||
}
|
};
|
||||||
|
|
||||||
public async rename(file: TAbstractFile, oldPath: string) {
|
public rename = async (file: TAbstractFile, oldPath: string) => {
|
||||||
Env.log.d(`SyncManager:rename isSuspended: ${this.isSuspended}`);
|
Env.log.d(`SyncManager:rename isSuspended: ${this.isSuspended}`);
|
||||||
if (this.isSuspended)
|
if (this.isSuspended)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (file instanceof TFile && this.dataStore.changeNoteID(asNoteID(oldPath), asNoteID(file), false))
|
if (file instanceof TFile && this.dataStore.changeNoteID(asNoteID(oldPath), asNoteID(file), false))
|
||||||
await this.dataStore.save();
|
await this.dataStore.save();
|
||||||
}
|
};
|
||||||
|
|
||||||
private static async processFile(file: TFile, app: App) {
|
private static async processFile(file: TFile, app: App) {
|
||||||
const { ids, output } = await DeclarationParser.getAllIDsInFile(file, app, (id) => id.isFrontSide)
|
const { ids, output } = await DeclarationParser.getAllIDsInFile(file, app, (id) => id.isFrontSide)
|
||||||
|
|
@ -104,7 +106,7 @@ export class SyncManager {
|
||||||
* The returned values are the {@link FullID}s of those declarations that were completed
|
* The returned values are the {@link FullID}s of those declarations that were completed
|
||||||
* and therefore also need to be saved to persistant storage.
|
* and therefore also need to be saved to persistant storage.
|
||||||
*
|
*
|
||||||
* This method is preferred to {@link postProcessWithFile} when the file can be modified via an
|
* This method is preferred over {@link postProcessWithFile} when the file can be modified via an
|
||||||
* {@link Editor} instance.
|
* {@link Editor} instance.
|
||||||
*
|
*
|
||||||
* @param editor
|
* @param editor
|
||||||
|
|
@ -138,8 +140,8 @@ export class SyncManager {
|
||||||
cursorPosition.line <= endPosBeforeModification.line)
|
cursorPosition.line <= endPosBeforeModification.line)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, existingIDs);
|
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
|
||||||
const replacement = CardDeclarationAssistant.toString(completeDeclaration);
|
const replacement = DeclarationCodec.toYaml(completeDeclaration);
|
||||||
|
|
||||||
// This will trigger file changed events.
|
// This will trigger file changed events.
|
||||||
editor.replaceRange(
|
editor.replaceRange(
|
||||||
|
|
@ -153,7 +155,7 @@ export class SyncManager {
|
||||||
autoGeneratedIDs.push(FullID.create(
|
autoGeneratedIDs.push(FullID.create(
|
||||||
info.noteID,
|
info.noteID,
|
||||||
completeDeclaration.id,
|
completeDeclaration.id,
|
||||||
CardDeclarationAssistant.isFrontSide(completeDeclaration)
|
ExplicitDeclarationAssistant.isFrontSide(completeDeclaration)
|
||||||
));
|
));
|
||||||
|
|
||||||
diff += replacement.length - (info.location.end - info.location.start);
|
diff += replacement.length - (info.location.end - info.location.start);
|
||||||
|
|
@ -196,9 +198,9 @@ export class SyncManager {
|
||||||
for (const info of replacementDeclarationInfos) {
|
for (const info of replacementDeclarationInfos) {
|
||||||
|
|
||||||
const startOffset = info.section.position.start.offset + info.location.start;
|
const startOffset = info.section.position.start.offset + info.location.start;
|
||||||
const replace = CardDeclarationAssistant.toString(info.declaration);
|
const replace = DeclarationCodec.toYaml(info.declaration);
|
||||||
const completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, existingIDs);
|
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
|
||||||
const replacement = CardDeclarationAssistant.toString(completeDeclaration);
|
const replacement = DeclarationCodec.toYaml(completeDeclaration);
|
||||||
|
|
||||||
parts.push(data.slice(sliceStartIndex, startOffset));
|
parts.push(data.slice(sliceStartIndex, startOffset));
|
||||||
parts.push(replacement);
|
parts.push(replacement);
|
||||||
|
|
@ -216,7 +218,7 @@ export class SyncManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link PostParseInfo.incompleteDeclarationInfos} should contain {@link DeclarationInfo}s
|
* {@link PostParseInfo.incompleteDeclarationInfos} should contain {@link DeclarationInfo}s
|
||||||
* that can be represented as statistics items and persisted in disk if some missing values are filled in.
|
* that can be represented as statistics items and persisted to disk if missing values are generated.
|
||||||
*
|
*
|
||||||
* This method filters out declarations that should not be persisted.
|
* This method filters out declarations that should not be persisted.
|
||||||
* @param postInfo As populated by {@link DeclarationParser.getAllIDsInFile} or {@link DeclarationParser.getAllIDsFromMetadata}.
|
* @param postInfo As populated by {@link DeclarationParser.getAllIDsInFile} or {@link DeclarationParser.getAllIDsFromMetadata}.
|
||||||
|
|
@ -225,10 +227,10 @@ export class SyncManager {
|
||||||
private static getFrontSideInvalidDeclarations(postInfo: PostParseInfo) {
|
private static getFrontSideInvalidDeclarations(postInfo: PostParseInfo) {
|
||||||
const declarations = postInfo.incompleteDeclarationInfos
|
const declarations = postInfo.incompleteDeclarationInfos
|
||||||
.filter(info => (
|
.filter(info => (
|
||||||
CardDeclarationAssistant.canMakeValidCardDeclarable(info.declaration) &&
|
ExplicitDeclarationAssistant.canComplete(info.declaration) &&
|
||||||
CardDeclarationAssistant.isFrontSide(info.declaration))
|
ExplicitDeclarationAssistant.isFrontSide(info.declaration))
|
||||||
);
|
);
|
||||||
console.assert(postInfo.incompleteDeclarationInfos.length == declarations.length, "Hmm")
|
|
||||||
return declarations;
|
return declarations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,7 +239,7 @@ export class SyncManager {
|
||||||
ids.push(FullID.create(
|
ids.push(FullID.create(
|
||||||
noteID,
|
noteID,
|
||||||
declarable.id,
|
declarable.id,
|
||||||
CardDeclarationAssistant.isFrontSide(declarable)
|
ExplicitDeclarationAssistant.isFrontSide(declarable)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,21 +255,29 @@ export class SyncManager {
|
||||||
private static async processFrontmatter(file: TFile, fileManager: FileManager, info: DeclarationInfo, preventIDs: Set<string>) {
|
private static async processFrontmatter(file: TFile, fileManager: FileManager, info: DeclarationInfo, preventIDs: Set<string>) {
|
||||||
let completeDeclaration: CardDeclarable | null = null;
|
let completeDeclaration: CardDeclarable | null = null;
|
||||||
|
|
||||||
if (DeclarationParser.isExternalSectionCache(info.section) && info.section.externalType === "frontmatter") {
|
if (DeclarationParser.isExternalSectionCache(info.section)) {
|
||||||
if (CardDeclarationAssistant.isValidCardDeclarable(info.declaration)) {
|
switch (info.section.externalType) {
|
||||||
console.assert(false, "Expected an invalid declaration.");
|
case UNARY_UNION_SUPPRESS:
|
||||||
completeDeclaration = null; // info.declaration;
|
break;
|
||||||
}
|
|
||||||
else {
|
case "frontmatter": {
|
||||||
// Set default values and modify the file.
|
if (ExplicitDeclarationAssistant.is(info.declaration) && ExplicitDeclarationAssistant.isValid(info.declaration)) {
|
||||||
completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, preventIDs);
|
Env.assert(false, "Expected an invalid declaration.");
|
||||||
const key = info.section.id;
|
completeDeclaration = null; // info.declaration;
|
||||||
if (isString(key)) {
|
}
|
||||||
// This seems to work fine while cursor is in the frontmatter (so no need to skip this declaration now because of that).
|
else {
|
||||||
await fileManager.processFrontMatter(file, (fm) => fm[key] = completeDeclaration).catch(console.error);
|
// Set default values and modify the file.
|
||||||
}
|
completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, preventIDs);
|
||||||
else {
|
const key = info.section.id;
|
||||||
console.assert(key, "Expected section id to be set to the frontmatter YAML key assinged to the declaration.");
|
if (isString(key)) {
|
||||||
|
// This seems to work fine while cursor is in the frontmatter (so no need to skip this declaration now because of that).
|
||||||
|
await fileManager.processFrontMatter(file, (fm) => fm[key] = completeDeclaration).catch(console.error);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Env.assert(key, "Expected section id to be set to the frontmatter YAML key assinged to the declaration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,8 +289,9 @@ export class SyncManager {
|
||||||
Env.log.d(`SyncManager:syncIDs isSuspended: ${this.isSuspended}, num IDs: ${ids.length}`);
|
Env.log.d(`SyncManager:syncIDs isSuspended: ${this.isSuspended}, num IDs: ${ids.length}`);
|
||||||
if (this.isSuspended)
|
if (this.isSuspended)
|
||||||
return;
|
return;
|
||||||
if (ids.length == 0)
|
|
||||||
return;
|
// Continue even if ids.length == 0
|
||||||
|
// The syncing mechanism looks at the diffs to be able to determine if IDs were removed.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.dataStore.syncData(ids, file.path, this.statisticsFactory);
|
this.dataStore.syncData(ids, file.path, this.statisticsFactory);
|
||||||
|
|
|
||||||
|
|
@ -1,228 +1,21 @@
|
||||||
import { DeckableDeclarable, Declarable, Declaration, DeclarationRange, YamlParseErrorCallback } from "declarations/Declaration";
|
import { CardDeclarable, DeclarationSide, IDScope } from "#/declarations/ExplicitDeclaration";
|
||||||
import { isString } from "TypeAssistant";
|
import { DeclarableProperty, NullableStringDeclarableProperty } from "#/declarations/Declarable";
|
||||||
import { UniqueID } from "data/UniqueID";
|
|
||||||
|
|
||||||
export const enum IDScope {
|
|
||||||
UNIQUE,
|
|
||||||
NOTE,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DeclarationSide = "front" | "back";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A {@link DefaultableCardDeclarable} where all default values are set.
|
|
||||||
*/
|
|
||||||
export interface CardDeclarable extends DefaultableCardDeclarable {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The minimum required propertes that need to be specified before
|
|
||||||
* default values and/or generated values can be applied
|
|
||||||
* to turn it into a {@link CardDeclarable}.
|
|
||||||
*
|
|
||||||
* See also: {@link CardDeclarationAssistant.conformsToDefaultable}.
|
|
||||||
*/
|
|
||||||
export interface DefaultableCardDeclarable extends DeckableDeclarable {
|
|
||||||
side: DeclarationSide;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeclarationInterfacePropertyName = {
|
|
||||||
ID: "id",
|
|
||||||
SIDE: "side",
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CardDeclaration implements CardDeclarable {
|
export class CardDeclaration implements CardDeclarable {
|
||||||
|
[key: string]: DeclarableProperty;
|
||||||
|
public deckID: NullableStringDeclarableProperty;
|
||||||
|
|
||||||
public readonly id: string;
|
public readonly id: string;
|
||||||
public readonly side: DeclarationSide;
|
public readonly side: DeclarationSide;
|
||||||
public deckID: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
|
|
||||||
public readonly isAutoGenerated: boolean = false;
|
public readonly isAutoGenerated: boolean = false;
|
||||||
public readonly idScope: IDScope;
|
public readonly idScope: IDScope;
|
||||||
|
|
||||||
public constructor(id: string, side: DeclarationSide, idScope: IDScope, deckID: string | null = null, isAutoGenerated = false) {
|
public constructor(id: string, side: DeclarationSide, idScope: IDScope, deckID: string | null = null, isAutoGenerated: boolean) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.side = side.trim().toLowerCase() as DeclarationSide;
|
this.side = side;
|
||||||
this.idScope = idScope;
|
this.idScope = idScope;
|
||||||
this.deckID = deckID;
|
this.deckID = deckID;
|
||||||
this.isAutoGenerated = isAutoGenerated;
|
this.isAutoGenerated = isAutoGenerated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isFrontSide(): boolean {
|
|
||||||
return CardDeclarationAssistant.isFrontSide(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CardDeclarationAssistant extends Declaration {
|
|
||||||
|
|
||||||
public static fromFrontmatter(maybeDeclaration: Record<string, unknown>, incompleteCallback?: (incomplete: DefaultableCardDeclarable, position: DeclarationRange) => void) {
|
|
||||||
if (this.conformsToDeclarable(maybeDeclaration))
|
|
||||||
return new CardDeclaration(maybeDeclaration.id, maybeDeclaration.side, IDScope.UNIQUE, maybeDeclaration.deckID);
|
|
||||||
if (this.conformsToDefaultable(maybeDeclaration) && incompleteCallback)
|
|
||||||
// This position should really be the position in the front matter YAML where the declaration is.
|
|
||||||
// But, since this is the frontmatter, there's no need slice strings as editing is done with `obsidian` `FileManager.processFrontMatter`.
|
|
||||||
incompleteCallback(maybeDeclaration, { start: 0, end: 0 });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param source The code block including the three ticks at the beginning and end.
|
|
||||||
* @param onParseError
|
|
||||||
* @param incompleteCallback Invoked if content of {@link source} is recognized but is missing required properties.
|
|
||||||
* @returns `null` if this block is unknown or it contains invalid YAML.
|
|
||||||
*/
|
|
||||||
public static parseCodeBlock(
|
|
||||||
source: string,
|
|
||||||
onParseError?: YamlParseErrorCallback,
|
|
||||||
incompleteCallback?: (incomplete: DefaultableCardDeclarable, position: DeclarationRange) => void) {
|
|
||||||
|
|
||||||
const info = super.parseAndCheckCodeBlock(source);
|
|
||||||
if (!info)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const maybeDeclaration = this.tryParseYaml(info.content, onParseError);
|
|
||||||
if (!maybeDeclaration)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (this.conformsToDeclarable(maybeDeclaration)) // Doesn't need to be valid here.
|
|
||||||
return new CardDeclaration(maybeDeclaration.id, maybeDeclaration.side, IDScope.UNIQUE, maybeDeclaration.deckID);
|
|
||||||
|
|
||||||
if (CardDeclarationAssistant.conformsToDefaultable(maybeDeclaration) && incompleteCallback)
|
|
||||||
incompleteCallback(maybeDeclaration, info.location satisfies DeclarationRange);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
//#region
|
|
||||||
|
|
||||||
public static canMakeValidCardDeclarable(decl: unknown): decl is DefaultableCardDeclarable {
|
|
||||||
// Cannot complete if its not the correct object.
|
|
||||||
if (!CardDeclarationAssistant.conformsToDefaultable(decl))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Already valid
|
|
||||||
if (CardDeclarationAssistant.isValidCardDeclarable(decl))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if all values of the given {@link CardDeclarable} are valid.
|
|
||||||
* @param decl
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public static isValidCardDeclarable(decl: Declarable): decl is CardDeclarable {
|
|
||||||
return (
|
|
||||||
CardDeclarationAssistant.conformsToDeclarable(decl) &&
|
|
||||||
|
|
||||||
// Check all values
|
|
||||||
CardDeclarationAssistant.isIDValid(decl.id) &&
|
|
||||||
CardDeclarationAssistant.isSideValid(decl.side) &&
|
|
||||||
CardDeclarationAssistant.isDeckIDValid(decl.deckID)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Completes a {@link DefaultableCardDeclarable} by assigning default and generated values
|
|
||||||
* to non-required properties.
|
|
||||||
*
|
|
||||||
* @param decl
|
|
||||||
* @param preventIDs See {@link UniqueID.generateID}
|
|
||||||
* @returns `null` if {@link canMakeValidCardDeclarable} returns `false`.
|
|
||||||
*/
|
|
||||||
private static tryToMakeValid(decl: DefaultableCardDeclarable, preventIDs?: Set<string>) {
|
|
||||||
if (this.canMakeValidCardDeclarable(decl)) {
|
|
||||||
const declWithDefaultValues = {
|
|
||||||
...decl,
|
|
||||||
...{
|
|
||||||
// Add all default values
|
|
||||||
id: UniqueID.generateID(preventIDs)
|
|
||||||
}
|
|
||||||
} satisfies CardDeclarable;
|
|
||||||
return declWithDefaultValues as CardDeclarable;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* May use if already checked with {@link canMakeValidCardDeclarable}.
|
|
||||||
* @param decl
|
|
||||||
* @param preventIDs See {@link UniqueID.generateID}
|
|
||||||
* @returns Result of calling {@link tryToMakeValid}.
|
|
||||||
*/
|
|
||||||
public static makeValidOrThrow(decl: DefaultableCardDeclarable, preventIDs?: Set<string>) {
|
|
||||||
const maybeCompleted = this.tryToMakeValid(decl, preventIDs);
|
|
||||||
if (maybeCompleted === null)
|
|
||||||
throw new Error("Could not complete given declaration block.");
|
|
||||||
return maybeCompleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if {@link value} conforms to {@link CardDeclarable};
|
|
||||||
* i.e., if the former can be cast to the latter.
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
* @returns `false` if {@link value} is `null`.
|
|
||||||
*/
|
|
||||||
public static conformsToDeclarable(value: unknown): value is CardDeclarable {
|
|
||||||
if (!CardDeclarationAssistant.conformsToDefaultable(value))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const id = value[DeclarationInterfacePropertyName.ID];
|
|
||||||
if (id === undefined)
|
|
||||||
value[DeclarationInterfacePropertyName.ID] = "";
|
|
||||||
if (isString(id))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false; // e.g. id is a number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if {@link value} conforms to {@link DefaultableCardDeclarable};
|
|
||||||
* i.e., if the former can be cast to the latter.
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
* @returns `false` if {@link value} is `null`.
|
|
||||||
*/
|
|
||||||
public static conformsToDefaultable(value: unknown): value is DefaultableCardDeclarable {
|
|
||||||
if (!CardDeclarationAssistant.conformsToDeckable(value))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (!Object.hasOwn(value, DeclarationInterfacePropertyName.SIDE))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static isIDValid(id: string) {
|
|
||||||
return UniqueID.isValid(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns `true` if {@link side} is one of the allowed values for {@link DefaultableCardDeclarable.side}
|
|
||||||
*/
|
|
||||||
private static isSideValid(side: string) {
|
|
||||||
return [
|
|
||||||
...this.frontSideValues,
|
|
||||||
...this.backSideValues
|
|
||||||
].includes(side.trim().toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isFrontSide(decl: DefaultableCardDeclarable, throwIfNotValid = false) {
|
|
||||||
if (throwIfNotValid && !this.isSideValid(decl.side))
|
|
||||||
throw new Error(`Side is not valid: ${decl.side}`);
|
|
||||||
return CardDeclarationAssistant.frontSideValues.includes(decl.side);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly frontSideValues = ["f", "front"];
|
|
||||||
private static readonly backSideValues = ["b", "back"];
|
|
||||||
|
|
||||||
//#endregion
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
src/declarations/Collectionable.ts
Normal file
82
src/declarations/Collectionable.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { UniqueID } from "#/data/UniqueID";
|
||||||
|
import { Declarable, DeclarableAssistant, NullableStringDeclarableProperty } from "#/declarations/Declarable";
|
||||||
|
import { LocalStrictKeys, Obj } from "#/utils/ts";
|
||||||
|
|
||||||
|
export interface DeckableDeclarable extends Declarable {
|
||||||
|
/** Optional. */
|
||||||
|
deckID: NullableStringDeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckableUserKeyName = {
|
||||||
|
DECK: "deck"
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeckablePropertyName = {
|
||||||
|
DECK_ID: "deckID",
|
||||||
|
};
|
||||||
|
|
||||||
|
type PropertyNames = LocalStrictKeys<DeckableDeclarable, Declarable>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers related to {@link DeckableDeclarable}.
|
||||||
|
*/
|
||||||
|
export class CollectionableAssistant extends DeclarableAssistant {
|
||||||
|
|
||||||
|
public static override is(value: unknown): value is DeckableDeclarable {
|
||||||
|
if (!DeclarableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Optional. Add if not exists
|
||||||
|
if (!Obj.hasKey<DeckableDeclarable, PropertyNames>(value, "deckID"))
|
||||||
|
Obj.setKey<DeckableDeclarable, PropertyNames>(value, "deckID", CollectionableAssistant.PropertyValue.FALLBACK_TO_SYSTEM_DEFAULT);
|
||||||
|
|
||||||
|
if (!DeclarableAssistant.PropertyType.isNullableString(Obj.getKey<DeckableDeclarable, PropertyNames>(value, "deckID")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override isValid(declarable: DeckableDeclarable) {
|
||||||
|
if (!DeclarableAssistant.isValid(declarable))
|
||||||
|
return false;
|
||||||
|
return declarable.deckID === null || UniqueID.isValid(declarable.deckID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a copy of the declaration with the specified deck ID.
|
||||||
|
*/
|
||||||
|
public static copyWithDeck<T extends DeckableDeclarable>(declaration: T, deckID: string | null): T {
|
||||||
|
return {
|
||||||
|
...declaration,
|
||||||
|
deckID: deckID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform user friendly YAML keys to interface/class properties.
|
||||||
|
* Opposite of {@link toUserFriendlyKeys}.
|
||||||
|
*
|
||||||
|
* @param obj
|
||||||
|
*/
|
||||||
|
public static override fromUserFriendlyKeys(obj: Record<string, unknown>) {
|
||||||
|
DeclarableAssistant.fromUserFriendlyKeys(obj);
|
||||||
|
|
||||||
|
if (Object.hasOwn(obj, DeckableUserKeyName.DECK)) {
|
||||||
|
const deckID = obj[DeckableUserKeyName.DECK];
|
||||||
|
delete obj[DeckableUserKeyName.DECK];
|
||||||
|
obj[DeckablePropertyName.DECK_ID] = deckID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override toUserFriendlyKeys(obj: Declarable) {
|
||||||
|
DeclarableAssistant.toUserFriendlyKeys(obj);
|
||||||
|
|
||||||
|
if (Object.hasOwn(obj, DeckablePropertyName.DECK_ID)) {
|
||||||
|
const deckID = obj[DeckablePropertyName.DECK_ID];
|
||||||
|
delete obj[DeckablePropertyName.DECK_ID];
|
||||||
|
// Only include if specific deck set.
|
||||||
|
if (deckID)
|
||||||
|
obj[DeckableUserKeyName.DECK] = deckID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { isObject, isString } from "TypeAssistant";
|
|
||||||
import { CommandDeclarationParsable } from "declarations/CommandDeclarationParser";
|
|
||||||
import { DeckableDeclarable, Declaration, DeclarationRange, YamlParseErrorCallback } from "declarations/Declaration";
|
|
||||||
import { AlternateHeadingsAssistant, AlternateHeadingsDeclarable } from "declarations/commands/AlternateHeadings";
|
|
||||||
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "declarations/commands/HeadingAndDelimiter";
|
|
||||||
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "declarations/commands/HeadingIsFront";
|
|
||||||
|
|
||||||
const AlternateHeadingsCommandNames = [
|
|
||||||
"alternate headings", "alt headings", "ah",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const HeadingAndDelimiterCommandNames = [
|
|
||||||
"heading and delimiter", "hd",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const HeadingIsFrontCommandNames = [
|
|
||||||
"heading is front", "hf",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const TableCommandNames = [
|
|
||||||
"table",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const CommandNames = [
|
|
||||||
...AlternateHeadingsCommandNames,
|
|
||||||
...HeadingAndDelimiterCommandNames,
|
|
||||||
...HeadingIsFrontCommandNames,
|
|
||||||
...TableCommandNames,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type CommandName = typeof CommandNames[number];
|
|
||||||
|
|
||||||
export interface CommandableDeclarable extends DeckableDeclarable {
|
|
||||||
name: CommandName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CommandDeclarationAssistant extends Declaration {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param source
|
|
||||||
* @param onParseError The formatting of {@link source} invalid YAML.
|
|
||||||
* @param onInvalidType
|
|
||||||
* @returns `null` if {@link source} is not recognized.
|
|
||||||
*/
|
|
||||||
public static createParser(
|
|
||||||
source: string,
|
|
||||||
onParseError?: YamlParseErrorCallback,
|
|
||||||
onInvalidType?: (command: CommandableDeclarable, range: DeclarationRange) => void) {
|
|
||||||
|
|
||||||
const info = super.parseAndCheckCodeBlock(source);
|
|
||||||
if (!info)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const obj = super.tryParseYaml(info.content, onParseError);
|
|
||||||
const commandable = obj && CommandDeclarationAssistant.conforms(obj) ? obj : null;
|
|
||||||
if (!commandable)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
let parser: CommandDeclarationParsable | null = null;
|
|
||||||
|
|
||||||
if (CommandDeclarationAssistant.isAlternateHeadings(commandable))
|
|
||||||
parser = AlternateHeadingsAssistant.tryCreateParser(commandable);
|
|
||||||
else if (CommandDeclarationAssistant.isHeadingAndDelimiter(commandable))
|
|
||||||
parser = HeadingAndDelimiterAssistant.tryCreateParser(commandable);
|
|
||||||
else if (CommandDeclarationAssistant.isHeadingIsFront(commandable))
|
|
||||||
parser = HeadingIsFrontAssistant.tryCreateParser(commandable);
|
|
||||||
|
|
||||||
if (!parser && onInvalidType)
|
|
||||||
onInvalidType(commandable, info.location satisfies DeclarationRange);
|
|
||||||
|
|
||||||
return parser;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isAlternateHeadings(declaration: CommandableDeclarable): declaration is AlternateHeadingsDeclarable {
|
|
||||||
return AlternateHeadingsCommandNames.includes(declaration.name as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isHeadingAndDelimiter(declaration: CommandableDeclarable): declaration is HeadingAndDelimiterDeclarable {
|
|
||||||
return HeadingAndDelimiterCommandNames.includes(declaration.name as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isHeadingIsFront(declaration: CommandableDeclarable): declaration is HeadingIsFrontDeclarable {
|
|
||||||
return HeadingIsFrontCommandNames.includes(declaration.name as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the provided object minimally conforms to the structure of a {@link CommandableDeclarable}.
|
|
||||||
* @param obj The object to check.
|
|
||||||
* @returns `true` if the object has at least the minimum properties expected of a {@link CommandableDeclarable}, `false` otherwise.
|
|
||||||
*/
|
|
||||||
public static conforms(obj: Record<string, any>): obj is CommandableDeclarable {
|
|
||||||
return (
|
|
||||||
isObject(obj) &&
|
|
||||||
Object.hasOwn(obj, "name") && isString(obj.name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the type value of the provided {@link CommandableDeclarable} is an valid/existing type.
|
|
||||||
* @param command
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public static isNameValid(command: CommandableDeclarable) {
|
|
||||||
return CommandNames.includes(command.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import { CardDeclaration, IDScope } from "declarations/CardDeclaration";
|
import { CardDeclaration } from "declarations/CardDeclaration";
|
||||||
import { CommandableDeclarable } from "declarations/CommandDeclaration";
|
import { CommandableDeclarable } from "declarations/Commandable";
|
||||||
import { FileParser, SectionRange } from "utils/obs/FileParser";
|
import { IDScope } from "declarations/ExplicitDeclaration";
|
||||||
import { CacheItem } from "obsidian";
|
import { CacheItem } from "obsidian";
|
||||||
|
import { FileParser, SectionRange } from "utils/obs/FileParser";
|
||||||
|
|
||||||
export interface CommandDeclarationParsable {
|
export interface CommandDeclarationParsable {
|
||||||
|
/**
|
||||||
|
* Creates a {@link CardDeclaration} and adds it to {@link generatedDeclarations}.
|
||||||
|
* @param sectionLevel
|
||||||
|
* @param inBetweenDelimiter
|
||||||
|
* @param index
|
||||||
|
* @param delimiters
|
||||||
|
*/
|
||||||
parse(sectionLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]): void;
|
parse(sectionLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]): void;
|
||||||
generatedDeclarations: GeneratedContentDeclaration[];
|
generatedDeclarations: GeneratedContentDeclaration[];
|
||||||
}
|
}
|
||||||
|
|
@ -16,10 +24,26 @@ export type GeneratedContentDeclaration = {
|
||||||
range: SectionRange,
|
range: SectionRange,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides common functionality for {@link CommandableDeclarable} parsers.
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
||||||
extends FileParser
|
extends FileParser
|
||||||
implements CommandDeclarationParsable {
|
implements CommandDeclarationParsable {
|
||||||
|
|
||||||
|
public static readonly Factory = {
|
||||||
|
createEntry: <T extends CommandableDeclarable>(
|
||||||
|
names: readonly string[],
|
||||||
|
ParserClass: {
|
||||||
|
new(commandable: T): CommandDeclarationParsable;
|
||||||
|
tryCreate(value: CommandableDeclarable): CommandDeclarationParsable | null;
|
||||||
|
}) => ({
|
||||||
|
names,
|
||||||
|
create: (d: CommandableDeclarable) => ParserClass.tryCreate(d)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
abstract parse(sectionLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]): void;
|
abstract parse(sectionLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]): void;
|
||||||
public generatedDeclarations: GeneratedContentDeclaration[] = [];
|
public generatedDeclarations: GeneratedContentDeclaration[] = [];
|
||||||
|
|
||||||
|
|
@ -30,7 +54,12 @@ export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
||||||
this.commandable = commandable;
|
this.commandable = commandable;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateDeclaration(id: string, isFront: boolean, startDelimiter: CacheItem | null, endDelimiter: CacheItem | null, scope: IDScope = IDScope.NOTE) {
|
protected generateDeclaration(
|
||||||
|
id: string,
|
||||||
|
isFront: boolean,
|
||||||
|
startDelimiter: CacheItem | null,
|
||||||
|
endDelimiter: CacheItem | null,
|
||||||
|
scope: IDScope = IDScope.Note) {
|
||||||
this.generatedDeclarations.push({
|
this.generatedDeclarations.push({
|
||||||
declaration: new CardDeclaration(
|
declaration: new CardDeclaration(
|
||||||
id,
|
id,
|
||||||
|
|
@ -59,4 +88,11 @@ export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
||||||
protected get lastID() {
|
protected get lastID() {
|
||||||
return this.lastDeclaration().declaration.id;
|
return this.lastDeclaration().declaration.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected tryParseUniqueID(text: string) {
|
||||||
|
const match = this.FULL_ID_REGEX.exec(text);
|
||||||
|
const result = match?.[1];
|
||||||
|
return result !== undefined ? result.toLowerCase() : null;
|
||||||
|
}
|
||||||
|
protected readonly FULL_ID_REGEX = /@([^\s]+)/i;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/declarations/CommandNames.ts
Normal file
35
src/declarations/CommandNames.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
const AlternateHeadingsCommandNames = [
|
||||||
|
"alternate headings", "alt headings", "ah",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HeadingAndDelimiterCommandNames = [
|
||||||
|
"heading and delimiter", "hd",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HeadingIsFrontCommandNames = [
|
||||||
|
"heading is front", "hf",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TableCommandNames = [
|
||||||
|
"table",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const AllCommandNames = [
|
||||||
|
...AlternateHeadingsCommandNames,
|
||||||
|
...HeadingAndDelimiterCommandNames,
|
||||||
|
...HeadingIsFrontCommandNames,
|
||||||
|
...TableCommandNames,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type CommandName = typeof AllCommandNames[number];
|
||||||
|
|
||||||
|
export const Commands = {
|
||||||
|
Name: {
|
||||||
|
All: AllCommandNames,
|
||||||
|
AlternateHeadings: AlternateHeadingsCommandNames,
|
||||||
|
HeadingAndDelimiter: HeadingAndDelimiterCommandNames,
|
||||||
|
HeadingIsFront: HeadingIsFrontCommandNames,
|
||||||
|
Table: TableCommandNames,
|
||||||
|
exists: (name: string): name is CommandName => (AllCommandNames as readonly string[]).includes(name),
|
||||||
|
}
|
||||||
|
};
|
||||||
40
src/declarations/Commandable.ts
Normal file
40
src/declarations/Commandable.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { CollectionableAssistant, DeckableDeclarable } from "#/declarations/Collectionable";
|
||||||
|
import { CommandName, Commands } from "#/declarations/CommandNames";
|
||||||
|
import { LocalStrictKeys, Obj, Str } from "#/utils/ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @abstract All commands extend this interface.
|
||||||
|
*/
|
||||||
|
export interface CommandableDeclarable extends DeckableDeclarable {
|
||||||
|
name: CommandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PropertyNames = LocalStrictKeys<CommandableDeclarable, DeckableDeclarable>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers related to {@link CommandableDeclarable}.
|
||||||
|
*/
|
||||||
|
export class CommandableAssistant extends CollectionableAssistant {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the provided object minimally conforms to the structure of a {@link CommandableDeclarable}.
|
||||||
|
* @param value The object to check.
|
||||||
|
* @returns `true` if the object has at least the minimum properties expected of a {@link CommandableDeclarable}, `false` otherwise.
|
||||||
|
*/
|
||||||
|
public static override is(value: unknown): value is CommandableDeclarable {
|
||||||
|
if (!CollectionableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Obj.hasKey<CommandableDeclarable, PropertyNames>(value, "name", (value) => Str.is(value)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override isValid(declaration: CommandableDeclarable): boolean {
|
||||||
|
if (!CollectionableAssistant.isValid(declaration))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return Commands.Name.exists(declaration.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/declarations/Declarable.ts
Normal file
102
src/declarations/Declarable.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { Bln, Null, Num, Obj, Str } from "#/utils/ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowable non-nullable types.
|
||||||
|
*
|
||||||
|
* - {@link DeclarableAssistant.PropertyType.isNullable} needs to be updated if types are added or removed.
|
||||||
|
*/
|
||||||
|
type NonNullableDeclarableProperty = string | number | boolean;
|
||||||
|
|
||||||
|
/** Allowable types. */
|
||||||
|
export type DeclarableProperty = NonNullableDeclarableProperty | null | undefined;
|
||||||
|
|
||||||
|
export type StringDeclarableProperty = Extract<DeclarableProperty, string>;
|
||||||
|
export type NullableStringDeclarableProperty = Extract<DeclarableProperty, string | null>;
|
||||||
|
export type OptionalNullableStringDeclarableProperty = Extract<DeclarableProperty, string | null | undefined>;
|
||||||
|
|
||||||
|
export type NumberDeclarableProperty = Extract<DeclarableProperty, number>;
|
||||||
|
export type NullableNumberDeclarableProperty = Extract<DeclarableProperty, number | null>;
|
||||||
|
export type OptionalNullableNumberDeclarableProperty = Extract<DeclarableProperty, number | null | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
export interface Declarable {
|
||||||
|
[key: string]: DeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers related to {@link Declarable}.
|
||||||
|
*/
|
||||||
|
export class DeclarableAssistant {
|
||||||
|
|
||||||
|
public static is(value: unknown): value is Declarable {
|
||||||
|
return Obj.is(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isValid(_value: unknown): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static readonly PropertyValue = {
|
||||||
|
/** Indicates that this optional property was not provided. */
|
||||||
|
OPTIONAL_NOT_ADDED: undefined,
|
||||||
|
/** "I am not providing a value, so use the system default." */
|
||||||
|
FALLBACK_TO_SYSTEM_DEFAULT: null,
|
||||||
|
/** "I am explicitly providing a value, and that value is 'nothing'." */
|
||||||
|
EXPLICIT_EMPTY_STRING: Str.EMPTY,
|
||||||
|
/** This property is of type {@link StringDeclarableProperty} and will be populated with a value programatically. Set it to this temporary value to satisfy the non-nullable type. */
|
||||||
|
AWAITING_VALUE: Str.EMPTY,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some of these do exactly the same as the {@link PropertyType} equivalents, but use them anyway for "semantic clarity".
|
||||||
|
*/
|
||||||
|
public static readonly PropertyEq = {
|
||||||
|
/** Property was not provided. */
|
||||||
|
optional: (value: unknown): value is typeof DeclarableAssistant.PropertyValue.OPTIONAL_NOT_ADDED =>
|
||||||
|
value === DeclarableAssistant.PropertyValue.OPTIONAL_NOT_ADDED,
|
||||||
|
|
||||||
|
optionalOrNull: (value: unknown): value is typeof DeclarableAssistant.PropertyValue.OPTIONAL_NOT_ADDED | typeof DeclarableAssistant.PropertyValue.FALLBACK_TO_SYSTEM_DEFAULT =>
|
||||||
|
value === DeclarableAssistant.PropertyValue.OPTIONAL_NOT_ADDED || value === null,
|
||||||
|
|
||||||
|
optionalNullOrString: (value: unknown, predicate?: (str: string) => boolean): value is OptionalNullableStringDeclarableProperty =>
|
||||||
|
Str.is(value) && (predicate === undefined || predicate(value)) || DeclarableAssistant.PropertyEq.optionalOrNull(value),
|
||||||
|
|
||||||
|
optionalNullOrNumber: (value: unknown, predicate?: (num: number) => boolean): value is OptionalNullableNumberDeclarableProperty =>
|
||||||
|
Num.is(value) && (predicate === undefined || predicate(value)) || DeclarableAssistant.PropertyEq.optionalOrNull(value),
|
||||||
|
|
||||||
|
any: (value: unknown): value is DeclarableProperty =>
|
||||||
|
Str.is(value) || Num.is(value) || Bln.is(value) || DeclarableAssistant.PropertyEq.optionalOrNull(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
protected static readonly PropertyType = {
|
||||||
|
|
||||||
|
isNullable: (value: unknown) => Null.is(value),
|
||||||
|
|
||||||
|
isNullableString: (value: unknown): value is NullableStringDeclarableProperty => Str.is(value) || Null.is(value),
|
||||||
|
|
||||||
|
isNullableNumber: (value: unknown): value is NullableNumberDeclarableProperty => Num.is(value) || Null.is(value),
|
||||||
|
|
||||||
|
isOptionalNullableString: (value: unknown): value is OptionalNullableStringDeclarableProperty =>
|
||||||
|
value === undefined || DeclarableAssistant.PropertyType.isNullableString(value),
|
||||||
|
|
||||||
|
isOptionalNullableNumber: (value: unknown): value is OptionalNullableNumberDeclarableProperty =>
|
||||||
|
value === undefined || DeclarableAssistant.PropertyType.isNullableNumber(value),
|
||||||
|
|
||||||
|
isStr: (value: unknown): value is StringDeclarableProperty => Str.is(value),
|
||||||
|
isNum: (value: unknown): value is NumberDeclarableProperty => Num.is(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform user friendly YAML keys to interface/class properties.
|
||||||
|
* Opposite of {@link toUserFriendlyKeys}.
|
||||||
|
*
|
||||||
|
* @param obj
|
||||||
|
*/
|
||||||
|
protected static fromUserFriendlyKeys(_obj: Record<string, unknown>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static toUserFriendlyKeys(_obj: Declarable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { FileParser } from "utils/obs/FileParser";
|
|
||||||
import { parseYaml, stringifyYaml } from "obsidian";
|
|
||||||
import { isObject, isString } from "TypeAssistant";
|
|
||||||
import { UniqueID } from "data/UniqueID";
|
|
||||||
|
|
||||||
export interface Declarable {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeckableDeclarable extends Declarable {
|
|
||||||
deckID: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeckableUserKeyName = {
|
|
||||||
DECK: "deck"
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeckablePropertyName = {
|
|
||||||
DECK_ID: "deckID",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locates the portion of a string that contains the raw declaration string.
|
|
||||||
* @todo Remove. See types in {@link FileParser}
|
|
||||||
*/
|
|
||||||
export interface DeclarationRange {
|
|
||||||
start: number,
|
|
||||||
end: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type YamlParseErrorCallback = (error: Error) => void;
|
|
||||||
|
|
||||||
export abstract class Declaration {
|
|
||||||
|
|
||||||
private static readonly LANGUAGE = "comethrough";
|
|
||||||
private static readonly LANGUAGE_SHORT = "ct";
|
|
||||||
|
|
||||||
public static get supportedCodeBlockLanguages() {
|
|
||||||
return [Declaration.LANGUAGE, Declaration.LANGUAGE_SHORT];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get supportedFrontmatterKeys() {
|
|
||||||
return [Declaration.LANGUAGE, Declaration.LANGUAGE_SHORT, "come through"];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static slice(source: string, location: DeclarationRange) {
|
|
||||||
return source.slice(location.start, location.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if this a code block with one of the expected languages; if so, parses it.
|
|
||||||
* @param source The code block including the three ticks at the beginning and end.
|
|
||||||
* @returns `null` is {@link source} is not a code block or if the block's language is unexpected.
|
|
||||||
*/
|
|
||||||
protected static parseAndCheckCodeBlock(source: string) {
|
|
||||||
const info = FileParser.parseCodeBlock(source);
|
|
||||||
|
|
||||||
if (info && !this.supportedCodeBlockLanguages.includes(info.language))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
private static readonly CODE_BLOCK_MARKER_LENGTH = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Case insensitive.
|
|
||||||
*
|
|
||||||
* @param yaml Will be converted to lower case before parsing.
|
|
||||||
* @param onParseError
|
|
||||||
* @returns `null` if YAML parsing failed, in which case {@link onParseError} will be invoked.
|
|
||||||
*/
|
|
||||||
public static tryParseYaml(yaml: string, onParseError?: YamlParseErrorCallback) {
|
|
||||||
let parsedObject: Record<string, any> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsedObject = parseYaml(yaml.toLowerCase());
|
|
||||||
if (parsedObject)
|
|
||||||
this.fromUserFriendlyKeys(parsedObject);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
if (error instanceof Error && error.name === "YAMLParseError")
|
|
||||||
onParseError?.(error);
|
|
||||||
else
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static toString(declaration: Declarable) {
|
|
||||||
this.toUserFriendlyKeys(declaration);
|
|
||||||
return stringifyYaml(declaration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform user friendly YAML keys to interface/class properties.
|
|
||||||
* Opposite of {@link toUserFriendlyKeys}.
|
|
||||||
*
|
|
||||||
* @param obj
|
|
||||||
*/
|
|
||||||
private static fromUserFriendlyKeys(obj: Record<string, any>) {
|
|
||||||
if (Object.hasOwn(obj, DeckableUserKeyName.DECK)) {
|
|
||||||
const deckID = obj[DeckableUserKeyName.DECK];
|
|
||||||
delete obj[DeckableUserKeyName.DECK];
|
|
||||||
obj[DeckablePropertyName.DECK_ID] = deckID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toUserFriendlyKeys(obj: Declarable) {
|
|
||||||
if (Object.hasOwn(obj, DeckablePropertyName.DECK_ID)) {
|
|
||||||
const deckID = obj[DeckablePropertyName.DECK_ID];
|
|
||||||
delete obj[DeckablePropertyName.DECK_ID];
|
|
||||||
// Only include if specific deck set.
|
|
||||||
if (deckID)
|
|
||||||
obj[DeckableUserKeyName.DECK] = deckID;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static conformsToDeclarableBase(value: unknown): value is Declarable {
|
|
||||||
return isObject(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static conformsToDeckable(value: unknown): value is DeckableDeclarable {
|
|
||||||
if (!Declaration.conformsToDeclarableBase(value))
|
|
||||||
return false;
|
|
||||||
if (!Object.hasOwn(value, DeckablePropertyName.DECK_ID))
|
|
||||||
value[DeckablePropertyName.DECK_ID] = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static copyWithDeck<T extends DeckableDeclarable>(declaration: T, deckID: string | null): T {
|
|
||||||
return {
|
|
||||||
...declaration,
|
|
||||||
...{
|
|
||||||
deckID: deckID,
|
|
||||||
} satisfies DeckableDeclarable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static isDeckIDValid(id: string | null) {
|
|
||||||
return id === null || (isString(id) && UniqueID.isValid(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
src/declarations/DeclarationCodec.ts
Normal file
54
src/declarations/DeclarationCodec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { CollectionableAssistant } from "#/declarations/Collectionable";
|
||||||
|
import { Declarable } from "#/declarations/Declarable";
|
||||||
|
import { Obj } from "#/utils/ts";
|
||||||
|
import { parseYaml, stringifyYaml } from "obsidian";
|
||||||
|
|
||||||
|
/** Key types are constrained to string keys only. */
|
||||||
|
export type YamlObject = Record<string, unknown>;
|
||||||
|
export type YamlParseErrorCallback = (error: Error) => void;
|
||||||
|
|
||||||
|
export class DeclarationCodec {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - Case insensitive.
|
||||||
|
* - Values of empty properties (e.g. `prop: `) are set to `null` by the YAML parser as `null` is a valid YAML value. `undefined` does not exist in YAML.
|
||||||
|
*
|
||||||
|
* @param yaml Will be converted to lower case before parsing.
|
||||||
|
* @param onParseError
|
||||||
|
* @returns `null` if YAML parsing failed, in which case {@link onParseError} will be invoked.
|
||||||
|
*/
|
||||||
|
public static tryFromYaml(yaml: string, onParseError?: YamlParseErrorCallback) {
|
||||||
|
let parsedObject: YamlObject | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = parseYaml(yaml);
|
||||||
|
|
||||||
|
if (Obj.is(raw)) {
|
||||||
|
parsedObject = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
// - YAML allows keys to be e.g. numbers. Make sure they are strings.
|
||||||
|
// - Also lowercase them to match property names in code (or camel cased)
|
||||||
|
parsedObject[String(key).toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the value has white space, e.g. `prop: `, `null` is still returned as YAML trims whitespace after the colon.
|
||||||
|
// However, content inside quotes are preserverd. `prop: " "` will have the value of a string with a space.
|
||||||
|
Obj.trimValues(parsedObject);
|
||||||
|
CollectionableAssistant.fromUserFriendlyKeys(parsedObject);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "YAMLParseError")
|
||||||
|
onParseError?.(error);
|
||||||
|
else
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static toYaml(declaration: Declarable): string {
|
||||||
|
CollectionableAssistant.toUserFriendlyKeys(declaration);
|
||||||
|
return stringifyYaml(declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { DataStore } from "data/DataStore";
|
import { DataStore } from "#/data/DataStore";
|
||||||
import { CardDeclarationAssistant } from "declarations/CardDeclaration";
|
import { DeckID } from "#/data/FullID";
|
||||||
import { DeckableDeclarable, Declaration } from "declarations/Declaration";
|
import { CollectionableAssistant, DeckableDeclarable } from "#/declarations/Collectionable";
|
||||||
import { DeclarationRenderChild } from "renderings/declarations/DeclarationRenderChild";
|
import { DeclarationConstants } from "#/declarations/constants";
|
||||||
import { DeckModal } from "modals/DeckModal";
|
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
|
||||||
|
import { Env } from "#/env";
|
||||||
|
import { DeckModal } from "#/modals/DeckModal";
|
||||||
|
import { DeclarationRenderChild } from "#/renderings/declarations/DeclarationRenderChild";
|
||||||
|
import { HtmlTag } from "#/utils/dom/constants";
|
||||||
import { App, MarkdownPostProcessorContext, MarkdownSectionInformation, TFile, Vault } from "obsidian";
|
import { App, MarkdownPostProcessorContext, MarkdownSectionInformation, TFile, Vault } from "obsidian";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,7 +17,7 @@ import { App, MarkdownPostProcessorContext, MarkdownSectionInformation, TFile, V
|
||||||
export class DeclarationManager {
|
export class DeclarationManager {
|
||||||
|
|
||||||
public static get supportedCodeBlockLanguages() {
|
public static get supportedCodeBlockLanguages() {
|
||||||
return Declaration.supportedCodeBlockLanguages;
|
return DeclarationConstants.CodeBlock.LANGUAGES;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async processCodeBlock(
|
public static async processCodeBlock(
|
||||||
|
|
@ -24,7 +28,8 @@ export class DeclarationManager {
|
||||||
data: DataStore) {
|
data: DataStore) {
|
||||||
|
|
||||||
const renderer = new DeclarationRenderChild(el, source, {
|
const renderer = new DeclarationRenderChild(el, source, {
|
||||||
getAllDecks: () => data.getAllDecks()
|
getAllDecks: () => data.getAllDecks(),
|
||||||
|
getDeck: (id: DeckID) => data.getDeck(id),
|
||||||
});
|
});
|
||||||
ctx.addChild(renderer); // The MarkdownPostProcessorContext manage unload, e.g., when file is closed.
|
ctx.addChild(renderer); // The MarkdownPostProcessorContext manage unload, e.g., when file is closed.
|
||||||
|
|
||||||
|
|
@ -36,44 +41,49 @@ export class DeclarationManager {
|
||||||
app.vault,
|
app.vault,
|
||||||
file,
|
file,
|
||||||
() => ctx.getSectionInfo(el),
|
() => ctx.getSectionInfo(el),
|
||||||
Declaration.toString(changedDeclaration)
|
DeclarationCodec.toYaml(changedDeclaration)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.render((declaration, type, deckSelectEl) => {
|
renderer.render((declaration, type, deckSelectEl) => {
|
||||||
|
|
||||||
const file = app.vault.getFileByPath(ctx.sourcePath);
|
const file = app.vault.getFileByPath(ctx.sourcePath);
|
||||||
console.assert(file);
|
Env.assert(file !== null);
|
||||||
if (!file)
|
if (!file)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (type === "deckAdded") {
|
switch (type) {
|
||||||
DeckModal.add(app, data, async (addedDeck) => {
|
case "deckAdded": {
|
||||||
|
DeckModal.add(app, data, async (addedDeck) => {
|
||||||
|
|
||||||
// Add a new option for the created deck
|
// Add a new option for the created deck
|
||||||
deckSelectEl.createEl("option", {
|
deckSelectEl.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||||
text: addedDeck.data.n,
|
text: addedDeck.data.n,
|
||||||
value: addedDeck.id,
|
value: addedDeck.id,
|
||||||
}, (el) => {
|
}, (el) => {
|
||||||
el.selected = true;
|
HtmlTag.SELECT.OPTION.select(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
handleChangedDeclaration(
|
||||||
|
CollectionableAssistant.copyWithDeck(declaration, addedDeck.id),
|
||||||
|
file
|
||||||
|
).catch(console.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "deckChanged": {
|
||||||
|
const selectedDeckID = HtmlTag.SELECT.OPTION.isNone(deckSelectEl.value) ? null : deckSelectEl.value;
|
||||||
|
if (selectedDeckID === declaration.deckID)
|
||||||
|
return;
|
||||||
|
|
||||||
handleChangedDeclaration(
|
handleChangedDeclaration(
|
||||||
CardDeclarationAssistant.copyWithDeck(declaration, addedDeck.id),
|
CollectionableAssistant.copyWithDeck(declaration, selectedDeckID),
|
||||||
file
|
file
|
||||||
).catch(console.error);
|
).catch(console.error);
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (type === "deckChanged") {
|
|
||||||
|
|
||||||
const selectedDeckID = deckSelectEl.value ? deckSelectEl.value : null;
|
break;
|
||||||
if (selectedDeckID === declaration.deckID)
|
}
|
||||||
return;
|
|
||||||
|
|
||||||
handleChangedDeclaration(
|
|
||||||
CardDeclarationAssistant.copyWithDeck(declaration, selectedDeckID),
|
|
||||||
file
|
|
||||||
).catch(console.error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { FullID, IDFilter, NoteID } from "data/FullID";
|
import { FullID, IDFilter, NoteID } from "#/data/FullID";
|
||||||
import { CardDeclarable, CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
|
import { CommandableAssistant, CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
import { CommandableDeclarable, CommandDeclarationAssistant } from "declarations/CommandDeclaration";
|
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||||
import { Declaration, DeclarationRange } from "declarations/Declaration";
|
import { DeclarationConstants } from "#/declarations/constants";
|
||||||
|
import { DeclarationCodec, YamlParseErrorCallback } from "#/declarations/DeclarationCodec";
|
||||||
|
import { CardDeclarable, DefaultableCardDeclarable, ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { ParserRegistry } from "#/declarations/ParserRegistry";
|
||||||
|
import { asNoteID, fullIDFromDeclaration } from "#/TypeAssistant";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { FileParser, OffsetRange, SectionRange } from "#/utils/obs/FileParser";
|
||||||
import { App, CachedMetadata, CacheItem, FrontMatterCache, HeadingCache, SectionCache, TFile } from "obsidian";
|
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.
|
* Contains auxiliary information collected during the parsing process.
|
||||||
|
|
@ -27,7 +30,7 @@ export interface PostParseInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All info needed to extract a {@link CardDeclarationAssistant|declaration block} from a note.
|
* All info needed to extract a {@link CardDeclarable | declaration block} from a note.
|
||||||
*/
|
*/
|
||||||
export interface DeclarationInfo extends DeclarationInfoBase {
|
export interface DeclarationInfo extends DeclarationInfoBase {
|
||||||
/** The declaration candidate. */
|
/** The declaration candidate. */
|
||||||
|
|
@ -45,7 +48,7 @@ interface DeclarationInfoBase {
|
||||||
/** The {@link SectionCache} in {@link noteID} where declaration was found. */
|
/** The {@link SectionCache} in {@link noteID} where declaration was found. */
|
||||||
section: SectionCache;
|
section: SectionCache;
|
||||||
/** The location of the declaration within the {@link section}. */
|
/** The location of the declaration within the {@link section}. */
|
||||||
location: DeclarationRange;
|
location: OffsetRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeclarationParser extends FileParser {
|
export class DeclarationParser extends FileParser {
|
||||||
|
|
@ -67,7 +70,7 @@ export class DeclarationParser extends FileParser {
|
||||||
|
|
||||||
let cache;
|
let cache;
|
||||||
try {
|
try {
|
||||||
cache = this.fileCacheOrThrow(app, file);
|
cache = this.fileCacheOrThrow(app, file);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
@ -78,7 +81,7 @@ export class DeclarationParser extends FileParser {
|
||||||
|
|
||||||
// Check frontmatter for explicit declaration
|
// Check frontmatter for explicit declaration
|
||||||
if (cache.frontmatter) {
|
if (cache.frontmatter) {
|
||||||
for (const key of Declaration.supportedFrontmatterKeys)
|
for (const key of DeclarationConstants.Frontmatter.KEYS)
|
||||||
if (Object.hasOwn(cache.frontmatter, key))
|
if (Object.hasOwn(cache.frontmatter, key))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -94,8 +97,8 @@ export class DeclarationParser extends FileParser {
|
||||||
for (const section of cache.sections) {
|
for (const section of cache.sections) {
|
||||||
if (this.isCodeSection(section)) {
|
if (this.isCodeSection(section)) {
|
||||||
if (fileContent) {
|
if (fileContent) {
|
||||||
const info = this.parseCodeBlock(this.extractContentFromSection(section, fileContent));
|
const info = DeclarationParser.parseCodeBlock(this.extractContentFromSection(section, fileContent));
|
||||||
if (info !== null && Declaration.supportedCodeBlockLanguages.includes(info.language))
|
if (info !== null && DeclarationConstants.CodeBlock.isSupportedLanguage(info.language))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|
@ -164,7 +167,7 @@ export class DeclarationParser extends FileParser {
|
||||||
const declaration = this.getDeclarationFromFrontmatter(cache.frontmatter, noteID, parseInfo);
|
const declaration = this.getDeclarationFromFrontmatter(cache.frontmatter, noteID, parseInfo);
|
||||||
if (declaration) {
|
if (declaration) {
|
||||||
const id = fullIDFromDeclaration(declaration, noteID);
|
const id = fullIDFromDeclaration(declaration, noteID);
|
||||||
if (!checkExistance(id) && (filter === undefined || (filter && filter(id))))
|
if (!checkExistance(id) && (filter === undefined || filter(id)))
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +176,7 @@ export class DeclarationParser extends FileParser {
|
||||||
for (const currentHeading of cache.headings ?? []) {
|
for (const currentHeading of cache.headings ?? []) {
|
||||||
const id = this.findFullIDInText(currentHeading.heading, noteID);
|
const id = this.findFullIDInText(currentHeading.heading, noteID);
|
||||||
if (id !== null && !checkExistance(id)) {
|
if (id !== null && !checkExistance(id)) {
|
||||||
if (filter === undefined || (filter && filter(id)))
|
if (filter === undefined || filter(id))
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +186,7 @@ export class DeclarationParser extends FileParser {
|
||||||
|
|
||||||
const createAndAddIDFromDeclaration = (declaration: CardDeclarable) => {
|
const createAndAddIDFromDeclaration = (declaration: CardDeclarable) => {
|
||||||
const id = fullIDFromDeclaration(declaration, noteID);
|
const id = fullIDFromDeclaration(declaration, noteID);
|
||||||
if (!checkExistance(id) && (filter === undefined || (filter && filter(id))))
|
if (!checkExistance(id) && (filter === undefined || filter(id)))
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,8 +239,8 @@ export class DeclarationParser extends FileParser {
|
||||||
* @returns The first declaration found in {@link frontmatter}.
|
* @returns The first declaration found in {@link frontmatter}.
|
||||||
*/
|
*/
|
||||||
protected static getDeclarationFromFrontmatter(frontmatter: FrontMatterCache, noteID: NoteID, parseInfo?: PostParseInfo) {
|
protected static getDeclarationFromFrontmatter(frontmatter: FrontMatterCache, noteID: NoteID, parseInfo?: PostParseInfo) {
|
||||||
for (const key of Declaration.supportedFrontmatterKeys) {
|
for (const key of DeclarationConstants.Frontmatter.KEYS) {
|
||||||
const declaration = CardDeclarationAssistant.fromFrontmatter(frontmatter[key], (incomplete, location) => {
|
const declaration = DeclarationParser.declarationFromFrontmatter(frontmatter[key], (incomplete, location) => {
|
||||||
parseInfo?.incompleteDeclarationInfos.push({
|
parseInfo?.incompleteDeclarationInfos.push({
|
||||||
noteID: noteID,
|
noteID: noteID,
|
||||||
declaration: incomplete,
|
declaration: incomplete,
|
||||||
|
|
@ -264,7 +267,7 @@ export class DeclarationParser extends FileParser {
|
||||||
|
|
||||||
const source = FileParser.extractContentFromSection(section, fileContent);
|
const source = FileParser.extractContentFromSection(section, fileContent);
|
||||||
|
|
||||||
return CardDeclarationAssistant.parseCodeBlock(
|
return DeclarationParser.createExplicitDeclaration(
|
||||||
source,
|
source,
|
||||||
(parseError) => {
|
(parseError) => {
|
||||||
parseInfo?.invalidYaml.push({
|
parseInfo?.invalidYaml.push({
|
||||||
|
|
@ -273,12 +276,12 @@ export class DeclarationParser extends FileParser {
|
||||||
error: parseError
|
error: parseError
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(incomplete, location) => {
|
(incomplete, range) => {
|
||||||
parseInfo?.incompleteDeclarationInfos.push({
|
parseInfo?.incompleteDeclarationInfos.push({
|
||||||
noteID: noteID,
|
noteID: noteID,
|
||||||
declaration: incomplete,
|
declaration: incomplete,
|
||||||
section: section,
|
section: section,
|
||||||
location: location,
|
location: range,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -298,7 +301,7 @@ export class DeclarationParser extends FileParser {
|
||||||
|
|
||||||
const source = fileContent.slice(section.position.start.offset, section.position.end.offset);
|
const source = fileContent.slice(section.position.start.offset, section.position.end.offset);
|
||||||
|
|
||||||
const parser = CommandDeclarationAssistant.createParser(
|
const parser = DeclarationParser.createCommandDeclarationParser(
|
||||||
source,
|
source,
|
||||||
(parseError) => {
|
(parseError) => {
|
||||||
parseInfo?.invalidYaml.push({
|
parseInfo?.invalidYaml.push({
|
||||||
|
|
@ -361,6 +364,98 @@ export class DeclarationParser extends FileParser {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to create a {@link CardDeclarable} from {@link source}.
|
||||||
|
*
|
||||||
|
* @param source The code block including the three ticks at the beginning and end.
|
||||||
|
* @param onParseError
|
||||||
|
* @param incompleteCallback Invoked if content of {@link source} is recognized but is missing required properties.
|
||||||
|
* @returns `null` if {@link source} is not recognized or it contains invalid YAML.
|
||||||
|
*/
|
||||||
|
private static createExplicitDeclaration(
|
||||||
|
source: string,
|
||||||
|
onParseError?: YamlParseErrorCallback,
|
||||||
|
incompleteCallback?: (incomplete: DefaultableCardDeclarable, range: OffsetRange) => void) {
|
||||||
|
|
||||||
|
const info = DeclarationParser.parseAndCheckCodeBlock(source);
|
||||||
|
if (info === null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const obj = DeclarationCodec.tryFromYaml(info.content, onParseError);
|
||||||
|
if (obj === null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const declaration = DeclarationParser.tryCreateDeclaration(obj);
|
||||||
|
|
||||||
|
if (declaration === null && ExplicitDeclarationAssistant.Defaultable.is(obj) && incompleteCallback)
|
||||||
|
incompleteCallback(obj, info.location);
|
||||||
|
|
||||||
|
return declaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static declarationFromFrontmatter(obj: Record<string, unknown>, incompleteCallback?: (incomplete: DefaultableCardDeclarable, range: OffsetRange) => void) {
|
||||||
|
const declaration = DeclarationParser.tryCreateDeclaration(obj);
|
||||||
|
|
||||||
|
if (declaration === null && ExplicitDeclarationAssistant.Defaultable.is(obj) && incompleteCallback) {
|
||||||
|
// This position should really be the position in the front matter YAML where the declaration is.
|
||||||
|
// But, since this is the frontmatter, there's no need slice strings as editing is done with `obsidian` `FileManager.processFrontMatter`.
|
||||||
|
incompleteCallback(obj, { start: 0, end: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return declaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to create a {@link CommandDeclarationParsable} from {@link source}.
|
||||||
|
*
|
||||||
|
* @param source The raw code block text string of the command declaration.
|
||||||
|
* @param onParseError The formatting of {@link source} invalid YAML.
|
||||||
|
* @param onInvalidType
|
||||||
|
* @returns `null` if {@link source} is not recognized.
|
||||||
|
*/
|
||||||
|
private static createCommandDeclarationParser(
|
||||||
|
source: string,
|
||||||
|
onParseError?: YamlParseErrorCallback,
|
||||||
|
onInvalidType?: (command: CommandableDeclarable, range: OffsetRange) => void): CommandDeclarationParsable | null {
|
||||||
|
|
||||||
|
const info = DeclarationParser.parseAndCheckCodeBlock(source);
|
||||||
|
if (!info)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const obj = DeclarationCodec.tryFromYaml(info.content, onParseError);
|
||||||
|
const commandable = obj !== null && CommandableAssistant.is(obj) ? obj : null;
|
||||||
|
|
||||||
|
let parser: CommandDeclarationParsable | null = null;
|
||||||
|
if (commandable !== null) {
|
||||||
|
|
||||||
|
parser = ParserRegistry.tryCreate(commandable);
|
||||||
|
|
||||||
|
if (parser === null && onInvalidType)
|
||||||
|
onInvalidType(commandable, info.location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this a code block with one of the expected languages; if so, parses it.
|
||||||
|
* @param source The code block including the three ticks at the beginning and end.
|
||||||
|
* @returns `null` is {@link source} is not a code block or if the block's language is unexpected.
|
||||||
|
*/
|
||||||
|
private static parseAndCheckCodeBlock(source: string) {
|
||||||
|
const info = FileParser.parseCodeBlock(source);
|
||||||
|
|
||||||
|
if (info !== null && !DeclarationConstants.CodeBlock.isSupportedLanguage(info.language))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns `null` if the {@link obj} is not a valid {@link CardDeclarable} */
|
||||||
|
private static tryCreateDeclaration(obj: Record<string, unknown>) {
|
||||||
|
return ExplicitDeclarationAssistant.is(obj) ? ExplicitDeclarationAssistant.createWithUniqueScope(obj) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the {@link SectionRange|range} that is associated and defined by {@link section} as its boundries.
|
* Finds the {@link SectionRange|range} that is associated and defined by {@link section} as its boundries.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
190
src/declarations/ExplicitDeclaration.ts
Normal file
190
src/declarations/ExplicitDeclaration.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { UniqueID } from "#/data/UniqueID";
|
||||||
|
import { CardDeclaration } from "#/declarations/CardDeclaration";
|
||||||
|
import { CollectionableAssistant, DeckableDeclarable } from "#/declarations/Collectionable";
|
||||||
|
import { Declarable, DeclarableAssistant, DeclarableProperty, OptionalNullableStringDeclarableProperty, StringDeclarableProperty } from "#/declarations/Declarable";
|
||||||
|
import { LocalStrictKeys, Obj } from "#/utils/ts";
|
||||||
|
|
||||||
|
export const IDScope = {
|
||||||
|
Unique: "unique",
|
||||||
|
Note: "note",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IDScope = typeof IDScope[keyof typeof IDScope];
|
||||||
|
|
||||||
|
export type DeclarationSide = "front" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link DefaultableCardDeclarable} where all default values are set.
|
||||||
|
*/
|
||||||
|
export interface CardDeclarable extends DefaultableCardDeclarable {
|
||||||
|
id: StringDeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimum required propertes that need to be specified before
|
||||||
|
* default values and/or generated values can be applied
|
||||||
|
* to turn it into a {@link CardDeclarable}.
|
||||||
|
*
|
||||||
|
* See also: {@link CardDeclarationAssistant.conformsToDefaultable}.
|
||||||
|
*/
|
||||||
|
export interface DefaultableCardDeclarable extends PageDeclarable, DeckableDeclarable {
|
||||||
|
id: OptionalNullableStringDeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @abstract */
|
||||||
|
export interface PageDeclarable extends MaybePageDeclarable {
|
||||||
|
side: DeclarationSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @sealed */
|
||||||
|
export interface MaybePageDeclarable extends Declarable {
|
||||||
|
side: DeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultablePropertyNames = LocalStrictKeys<DefaultableCardDeclarable, DeckableDeclarable>;
|
||||||
|
type PagePropertyNames = LocalStrictKeys<PageDeclarable, DeckableDeclarable>;
|
||||||
|
|
||||||
|
export class ExplicitDeclarationAssistant extends CollectionableAssistant {
|
||||||
|
|
||||||
|
/** @returns `null` if the {@link declaration} is invalid */
|
||||||
|
public static createWithUniqueScope(declaration: CardDeclarable) {
|
||||||
|
if (!ExplicitDeclarationAssistant.isValid(declaration))
|
||||||
|
return null;
|
||||||
|
return new CardDeclaration(declaration.id, declaration.side, IDScope.Unique, declaration.deckID, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns `true` if {@link value} is a valid {@link DefaultableCardDeclarable} and conforms to {@link CardDeclarable}. */
|
||||||
|
public static canComplete(value: DefaultableCardDeclarable): value is CardDeclarable {
|
||||||
|
if (!ExplicitDeclarationAssistant.Defaultable.isValid(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Completing the values of the top-level interface is the same as making it valid.
|
||||||
|
// So if it conforms to the top-level interface it is ready.
|
||||||
|
// Any additional non-completable properties should be added to sub interfaces.
|
||||||
|
if (!ExplicitDeclarationAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// value is top-level interface
|
||||||
|
// - Type of `id` is narrowed.
|
||||||
|
return value.id === ExplicitDeclarationAssistant.PropertyValue.AWAITING_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a {@link DefaultableCardDeclarable} valid by assigning default and generated values to non-required properties.
|
||||||
|
* May use if already checked with {@link canComplete}.
|
||||||
|
* @param decl
|
||||||
|
* @param preventIDs See {@link UniqueID.generateID}
|
||||||
|
* @returns `null` if {@link canComplete} returns `false`. If {@link declaration} is already a valid {@link CardDeclarable}, returns the same unmodified {@link declaration}. Otherwise, returns a valid {@link CardDeclarable}.
|
||||||
|
* @throws If {@link canComplete} returns `false` or {@link declaration} is already complete/valid.
|
||||||
|
*/
|
||||||
|
public static completeOrThrow(declaration: DefaultableCardDeclarable, preventIDs?: Set<string>) {
|
||||||
|
|
||||||
|
if (!ExplicitDeclarationAssistant.canComplete(declaration))
|
||||||
|
throw new Error("Could not complete declaration.");
|
||||||
|
|
||||||
|
if (ExplicitDeclarationAssistant.isValid(declaration))
|
||||||
|
throw new Error("Declaration already complete.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...declaration,
|
||||||
|
id: UniqueID.generateID(preventIDs)
|
||||||
|
} satisfies CardDeclarable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override isValid(declarable: CardDeclarable) {
|
||||||
|
if (!ExplicitDeclarationAssistant.Defaultable.isValid(declarable))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!UniqueID.isValid(declarable.id))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static override is(value: unknown): value is CardDeclarable {
|
||||||
|
if (!ExplicitDeclarationAssistant.Defaultable.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (ExplicitDeclarationAssistant.PropertyEq.optionalOrNull(Obj.getKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "id")))
|
||||||
|
Obj.setKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "id", ExplicitDeclarationAssistant.PropertyValue.AWAITING_VALUE);
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly Defaultable = {
|
||||||
|
|
||||||
|
is(value: unknown): value is DefaultableCardDeclarable {
|
||||||
|
if (!CollectionableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Obj.hasKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "side"))
|
||||||
|
return false;
|
||||||
|
if (!ExplicitDeclarationAssistant.PropertyType.isStr(Obj.getKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "side")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Make sure optional properties are added to the object.
|
||||||
|
if (!Obj.hasKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "id"))
|
||||||
|
Obj.setKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "id", ExplicitDeclarationAssistant.PropertyValue.OPTIONAL_NOT_ADDED);
|
||||||
|
|
||||||
|
if (!ExplicitDeclarationAssistant.PropertyType.isOptionalNullableString(Obj.getKey<DefaultableCardDeclarable, DefaultablePropertyNames>(value, "id")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
isValid(declarable: DefaultableCardDeclarable) {
|
||||||
|
if (!CollectionableAssistant.isValid(declarable))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!ExplicitDeclarationAssistant.isSideValid(declarable.side))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!ExplicitDeclarationAssistant.PropertyEq.optionalNullOrString(declarable.id))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly MaybePage = {
|
||||||
|
|
||||||
|
is(value: unknown): value is MaybePageDeclarable {
|
||||||
|
if (!CollectionableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Obj.hasKey<MaybePageDeclarable, PagePropertyNames>(value, "side"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
isValid(declarable: MaybePageDeclarable): boolean {
|
||||||
|
if (!DeclarableAssistant.isValid(declarable))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!ExplicitDeclarationAssistant.PropertyEq.any(declarable.side))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static isFrontSide(decl: DefaultableCardDeclarable, throwIfNotValid = false) {
|
||||||
|
if (throwIfNotValid && !this.isSideValid(decl.side))
|
||||||
|
throw new Error(`Side is not valid: ${decl.side}`);
|
||||||
|
return ExplicitDeclarationAssistant.frontSideValues.includes(decl.side);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns `true` if {@link side} is one of the allowed values for {@link DefaultableCardDeclarable.side}
|
||||||
|
*/
|
||||||
|
private static isSideValid(side: string) {
|
||||||
|
return [
|
||||||
|
...this.frontSideValues,
|
||||||
|
...this.backSideValues
|
||||||
|
].includes(side.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly frontSideValues = ["f", "front"];
|
||||||
|
private static readonly backSideValues = ["b", "back"];
|
||||||
|
}
|
||||||
19
src/declarations/ParserRegistry.ts
Normal file
19
src/declarations/ParserRegistry.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
|
import { CommandDeclarationParsable, CommandDeclarationParser } from "#/declarations/CommandDeclarationParser";
|
||||||
|
import { Commands } from "#/declarations/CommandNames";
|
||||||
|
import { AlternateHeadingsParser } from "#/declarations/commands/AlternateHeadings";
|
||||||
|
import { HeadingAndDelimiterParser } from "#/declarations/commands/HeadingAndDelimiter";
|
||||||
|
import { HeadingIsFrontParser } from "#/declarations/commands/HeadingIsFront";
|
||||||
|
|
||||||
|
const REGISTRY = [
|
||||||
|
CommandDeclarationParser.Factory.createEntry(Commands.Name.AlternateHeadings, AlternateHeadingsParser),
|
||||||
|
CommandDeclarationParser.Factory.createEntry(Commands.Name.HeadingAndDelimiter, HeadingAndDelimiterParser),
|
||||||
|
CommandDeclarationParser.Factory.createEntry(Commands.Name.HeadingIsFront, HeadingIsFrontParser),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ParserRegistry = {
|
||||||
|
tryCreate(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||||
|
const parsable = REGISTRY.find(e => e.names.includes(commandable.name));
|
||||||
|
return parsable !== undefined ? parsable.create(commandable) : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,24 +1,38 @@
|
||||||
import { CommandableDeclarable } from "declarations/CommandDeclaration";
|
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
import { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "declarations/commands/HeadingsCommandable";
|
import { Commands, CommandName } from "#/declarations/CommandNames";
|
||||||
import { CacheItem, HeadingCache } from "obsidian";
|
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "#/declarations/commands/HeadingsCommandable";
|
||||||
|
|
||||||
export interface AlternateHeadingsDeclarable extends HeadingsCommandableDeclarable { }
|
import { FileParser } from "#/utils/obs/FileParser";
|
||||||
|
import { CacheItem } from "obsidian";
|
||||||
|
|
||||||
|
export interface AlternateHeadingsDeclarable extends HeadingsCommandableDeclarable { // eslint-disable-line @typescript-eslint/no-empty-object-type
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helpers related to {@link AlternateHeadingsDeclarable}. */
|
||||||
export class AlternateHeadingsAssistant extends HeadingsCommandableAssistant {
|
export class AlternateHeadingsAssistant extends HeadingsCommandableAssistant {
|
||||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
|
||||||
return (
|
public static override is(value: unknown): value is AlternateHeadingsDeclarable {
|
||||||
AlternateHeadingsAssistant.conforms<AlternateHeadingsDeclarable>(commandable) &&
|
if (!HeadingsCommandableAssistant.is(value))
|
||||||
AlternateHeadingsAssistant.isValid(commandable)
|
return false;
|
||||||
) ? new AlternateHeadingsParser(commandable) : null;
|
|
||||||
|
return (Commands.Name.AlternateHeadings as readonly CommandName[]).includes(value.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const ThisAssistant = AlternateHeadingsAssistant;
|
||||||
|
|
||||||
export class AlternateHeadingsParser extends HeadingsDeclarationParser<AlternateHeadingsDeclarable> {
|
export class AlternateHeadingsParser extends HeadingsDeclarationParser<AlternateHeadingsDeclarable> {
|
||||||
|
|
||||||
|
public static tryCreate(declarable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||||
|
if (ThisAssistant.is(declarable) && ThisAssistant.isValid(declarable))
|
||||||
|
return new this(declarable);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
||||||
|
|
||||||
if (!AlternateHeadingsParser.isHeadingCache(inBetweenDelimiter))
|
if (!FileParser.isHeadingCache(inBetweenDelimiter))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only interested in headings on the specified level
|
// Only interested in headings on the specified level
|
||||||
|
|
@ -26,10 +40,31 @@ export class AlternateHeadingsParser extends HeadingsDeclarationParser<Alternate
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const isFront = this.generatedDeclarations.length % 2 == 0;
|
const isFront = this.generatedDeclarations.length % 2 == 0;
|
||||||
|
let id: string;
|
||||||
|
let idScope: IDScope;
|
||||||
|
|
||||||
|
if (isFront) {
|
||||||
|
const uniqueID = this.tryParseUniqueID(inBetweenDelimiter.heading);
|
||||||
|
if (uniqueID !== null) {
|
||||||
|
id = uniqueID;
|
||||||
|
idScope = IDScope.Unique;
|
||||||
|
} else {
|
||||||
|
id = inBetweenDelimiter.heading;
|
||||||
|
idScope = IDScope.Note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const decl = this.lastDeclaration().declaration;
|
||||||
|
id = decl.id;
|
||||||
|
idScope = decl.idScope;
|
||||||
|
}
|
||||||
|
|
||||||
this.generateDeclaration(
|
this.generateDeclaration(
|
||||||
isFront ? inBetweenDelimiter.heading : this.lastID,
|
id,
|
||||||
isFront,
|
isFront,
|
||||||
inBetweenDelimiter,
|
inBetweenDelimiter,
|
||||||
AlternateHeadingsParser.findNextHeading(inBetweenDelimiter.level, index, delimiters));
|
HeadingsDeclarationParser.findNextHeading(inBetweenDelimiter.level, index, delimiters),
|
||||||
|
idScope
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,31 @@
|
||||||
import { IDScope } from "declarations/CardDeclaration";
|
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
import { CommandableDeclarable } from "declarations/CommandDeclaration";
|
import { CommandDeclarationParsable, CommandDeclarationParser } from "#/declarations/CommandDeclarationParser";
|
||||||
import { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
import { CommandName, Commands } from "#/declarations/CommandNames";
|
||||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable } from "declarations/commands/HeadingsCommandable";
|
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable } from "#/declarations/commands/HeadingsCommandable";
|
||||||
|
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { UNARY_UNION_SUPPRESS } from "#/utils/ts";
|
||||||
import { CacheItem, HeadingCache } from "obsidian";
|
import { CacheItem, HeadingCache } from "obsidian";
|
||||||
import { UnexpectedUndefinedError } from "utils/errors";
|
|
||||||
|
|
||||||
export interface HeadingAndDelimiterDeclarable extends HeadingsCommandableDeclarable {
|
export interface HeadingAndDelimiterDeclarable extends HeadingsCommandableDeclarable {
|
||||||
delimiter: "horizontal rule"
|
delimiter: "horizontal rule" | typeof UNARY_UNION_SUPPRESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Helpers related to {@link HeadingAndDelimiterDeclarable}. */
|
||||||
export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
||||||
|
|
||||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
public static override is(value: unknown): value is HeadingAndDelimiterDeclarable {
|
||||||
return (
|
if (!HeadingsCommandableAssistant.is(value))
|
||||||
HeadingAndDelimiterAssistant.conforms<HeadingAndDelimiterDeclarable>(commandable) &&
|
return false;
|
||||||
HeadingAndDelimiterAssistant.isValid(commandable)
|
|
||||||
) ? new HeadingAndDelimiterParser(commandable) : null;
|
return (Commands.Name.HeadingAndDelimiter as readonly CommandName[]).includes(value.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static isValid(command: HeadingAndDelimiterDeclarable) {
|
public static override isValid(declarable: HeadingAndDelimiterDeclarable) {
|
||||||
return (
|
if (!HeadingsCommandableAssistant.isValid(declarable))
|
||||||
super.isValid(command) &&
|
return false;
|
||||||
this.isDelimiterValid(command)
|
|
||||||
);
|
return ThisAssistant.isDelimiterValid(declarable);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isDelimiterValid(command: HeadingAndDelimiterDeclarable) {
|
private static isDelimiterValid(command: HeadingAndDelimiterDeclarable) {
|
||||||
|
|
@ -32,6 +35,7 @@ export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
||||||
switch (command.delimiter as string) {
|
switch (command.delimiter as string) {
|
||||||
case "hr":
|
case "hr":
|
||||||
setDefault();
|
setDefault();
|
||||||
|
return true;
|
||||||
case "horizontal rule":
|
case "horizontal rule":
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -44,12 +48,17 @@ export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const ThisAssistant = HeadingAndDelimiterAssistant;
|
||||||
|
|
||||||
export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingAndDelimiterDeclarable> {
|
export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingAndDelimiterDeclarable> {
|
||||||
|
|
||||||
/**
|
public static tryCreate(declarable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||||
* If set, it means that the current iteration is the back side and that this is the expected level of the heading that marks the end of the back side.
|
if (ThisAssistant.is(declarable) && ThisAssistant.isValid(declarable))
|
||||||
*/
|
return new this(declarable)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If set, it means that the current iteration is the back side and that this is the expected level of the heading that marks the end of the back side. */
|
||||||
private lastFrontHeadingLevel: number | undefined;
|
private lastFrontHeadingLevel: number | undefined;
|
||||||
|
|
||||||
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
||||||
|
|
@ -63,17 +72,17 @@ export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingA
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: string;
|
let id: string;
|
||||||
let idScope;
|
let idScope: IDScope;
|
||||||
|
|
||||||
if (isDelimiterHeading) {
|
if (isDelimiterHeading) {
|
||||||
const uniqueID = this.tryParseUniqueID(inBetweenDelimiter.heading);
|
const uniqueID = this.tryParseUniqueID(inBetweenDelimiter.heading);
|
||||||
if (uniqueID) {
|
if (uniqueID !== null) {
|
||||||
id = uniqueID;
|
id = uniqueID;
|
||||||
idScope = IDScope.UNIQUE;
|
idScope = IDScope.Unique;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
id = inBetweenDelimiter.heading;
|
id = inBetweenDelimiter.heading;
|
||||||
idScope = IDScope.NOTE;
|
idScope = IDScope.Note;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (HeadingAndDelimiterParser.isSectionType(inBetweenDelimiter, HeadingAndDelimiterParser.SECTION_TYPE_THEMATICBREAK)) {
|
else if (HeadingAndDelimiterParser.isSectionType(inBetweenDelimiter, HeadingAndDelimiterParser.SECTION_TYPE_THEMATICBREAK)) {
|
||||||
|
|
@ -119,12 +128,4 @@ export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingA
|
||||||
private isOnSpecifiedLevel(parentHeadingLevel: number, section: HeadingCache) {
|
private isOnSpecifiedLevel(parentHeadingLevel: number, section: HeadingCache) {
|
||||||
return section.level == parentHeadingLevel + this.commandable.level;
|
return section.level == parentHeadingLevel + this.commandable.level;
|
||||||
}
|
}
|
||||||
|
|
||||||
private tryParseUniqueID(text: string) {
|
|
||||||
const match = this.FULL_ID_REGEX.exec(text);
|
|
||||||
const result = match?.[1];
|
|
||||||
return result !== undefined ? result.toLowerCase() : null;
|
|
||||||
}
|
|
||||||
protected readonly FULL_ID_REGEX = /@([^\s]+)/i;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,33 @@
|
||||||
import { CommandableDeclarable } from "declarations/CommandDeclaration";
|
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
import { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "declarations/commands/HeadingsCommandable";
|
import { CommandName, Commands } from "#/declarations/CommandNames";
|
||||||
import { CacheItem, HeadingCache, Loc } from "obsidian";
|
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "#/declarations/commands/HeadingsCommandable";
|
||||||
|
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { CacheItem, Loc } from "obsidian";
|
||||||
|
|
||||||
export interface HeadingIsFrontDeclarable extends HeadingsCommandableDeclarable { }
|
export interface HeadingIsFrontDeclarable extends HeadingsCommandableDeclarable { // eslint-disable-line @typescript-eslint/no-empty-object-type
|
||||||
|
|
||||||
export class HeadingIsFrontAssistant extends HeadingsCommandableAssistant {
|
|
||||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
|
||||||
return (
|
|
||||||
HeadingIsFrontAssistant.conforms<HeadingIsFrontDeclarable>(commandable) &&
|
|
||||||
HeadingIsFrontAssistant.isValid(commandable)
|
|
||||||
) ? new HeadingIsFrontParser(commandable) : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Helpers related to {@link HeadingIsFrontDeclarable}. */
|
||||||
|
export class HeadingIsFrontAssistant extends HeadingsCommandableAssistant {
|
||||||
|
|
||||||
|
public static override is(value: unknown): value is HeadingIsFrontDeclarable {
|
||||||
|
if (!HeadingsCommandableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (Commands.Name.HeadingIsFront as readonly CommandName[]).includes(value.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ThisAssistant = HeadingIsFrontAssistant;
|
||||||
|
|
||||||
export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFrontDeclarable> {
|
export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFrontDeclarable> {
|
||||||
|
|
||||||
|
public static tryCreate(declarable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||||
|
if (ThisAssistant.is(declarable) && ThisAssistant.isValid(declarable))
|
||||||
|
return new this(declarable);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
||||||
if (!HeadingIsFrontParser.isHeadingCache(inBetweenDelimiter))
|
if (!HeadingIsFrontParser.isHeadingCache(inBetweenDelimiter))
|
||||||
return;
|
return;
|
||||||
|
|
@ -24,7 +36,15 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
||||||
if (!this.isOnSpecifiedLevel(parentHeadingLevel, inBetweenDelimiter))
|
if (!this.isOnSpecifiedLevel(parentHeadingLevel, inBetweenDelimiter))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const id = inBetweenDelimiter.heading;
|
let id = inBetweenDelimiter.heading;
|
||||||
|
let idScope: IDScope = IDScope.Note;
|
||||||
|
|
||||||
|
const uniqueID = this.tryParseUniqueID(inBetweenDelimiter.heading);
|
||||||
|
if (uniqueID !== null) {
|
||||||
|
id = uniqueID;
|
||||||
|
idScope = IDScope.Unique;
|
||||||
|
}
|
||||||
|
|
||||||
const startLocation: Loc = {
|
const startLocation: Loc = {
|
||||||
line: inBetweenDelimiter.position.start.line,
|
line: inBetweenDelimiter.position.start.line,
|
||||||
col: inBetweenDelimiter.position.start.col,
|
col: inBetweenDelimiter.position.start.col,
|
||||||
|
|
@ -50,7 +70,9 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
||||||
start: endLocation,
|
start: endLocation,
|
||||||
end: endLocation,
|
end: endLocation,
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
idScope,
|
||||||
|
);
|
||||||
|
|
||||||
this.generateDeclaration(
|
this.generateDeclaration(
|
||||||
id,
|
id,
|
||||||
|
|
@ -61,7 +83,8 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
||||||
end: endLocation,
|
end: endLocation,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
HeadingIsFrontParser.findNextHeading(inBetweenDelimiter.level, index, delimiters)
|
HeadingsDeclarationParser.findNextHeading(inBetweenDelimiter.level, index, delimiters),
|
||||||
|
idScope,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,79 @@
|
||||||
import { CommandableDeclarable } from "declarations/CommandDeclaration";
|
import { CommandableAssistant, CommandableDeclarable } from "#/declarations/Commandable";
|
||||||
import { CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
import { CommandDeclarationParser } from "#/declarations/CommandDeclarationParser";
|
||||||
|
import { NumberDeclarableProperty, OptionalNullableNumberDeclarableProperty } from "#/declarations/Declarable";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { LocalStrictKeys, Obj } from "#/utils/ts";
|
||||||
import { CacheItem, HeadingCache } from "obsidian";
|
import { CacheItem, HeadingCache } from "obsidian";
|
||||||
import { isNumber } from "TypeAssistant";
|
|
||||||
import { UnexpectedUndefinedError } from "utils/errors";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @abstract
|
* A command declaration that uses headings as dividers.
|
||||||
*/
|
* @abstract
|
||||||
export interface HeadingsCommandableDeclarable extends CommandableDeclarable {
|
*/
|
||||||
level: number;
|
export interface HeadingsCommandableDeclarable extends DefaultableHeadingsCommandableDeclarable {
|
||||||
|
level: NumberDeclarableProperty;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class HeadingsCommandableAssistant {
|
export interface DefaultableHeadingsCommandableDeclarable extends CommandableDeclarable {
|
||||||
|
level: OptionalNullableNumberDeclarableProperty;
|
||||||
|
}
|
||||||
|
|
||||||
public static conforms<T extends HeadingsCommandableDeclarable>(command: CommandableDeclarable): command is T {
|
type PropertyNames = LocalStrictKeys<DefaultableHeadingsCommandableDeclarable, CommandableDeclarable>;
|
||||||
return Object.hasOwn(command, "level");
|
|
||||||
|
|
||||||
|
export abstract class HeadingsCommandableAssistant extends CommandableAssistant {
|
||||||
|
|
||||||
|
public static override is(value: unknown): value is HeadingsCommandableDeclarable {
|
||||||
|
if (!This.Defaultable.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!This.PropertyType.isNum(Obj.getKey<DefaultableHeadingsCommandableDeclarable, PropertyNames>(value, "level")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static override isValid(declarable: HeadingsCommandableDeclarable) {
|
||||||
* @param command
|
if (!This.Defaultable.isValid(declarable))
|
||||||
* @returns `true` if the values of properties were valid or undefined, in which case default values were set.
|
return false;
|
||||||
*/
|
|
||||||
public static isValid(command: HeadingsCommandableDeclarable) {
|
if (declarable.level < 1)
|
||||||
return (
|
return false;
|
||||||
this.isLevelValid(command)
|
|
||||||
);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isLevelValid(command: HeadingsCommandableDeclarable) {
|
public static readonly Defaultable = {
|
||||||
if (isNumber(command.level)) {
|
|
||||||
return command.level >= 1;
|
is(value: unknown): value is DefaultableHeadingsCommandableDeclarable {
|
||||||
}
|
if (!CommandableAssistant.is(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Add optional properties with default values.
|
||||||
|
if (!Obj.hasKey<DefaultableHeadingsCommandableDeclarable, PropertyNames>(value, "level"))
|
||||||
|
Obj.setKey<DefaultableHeadingsCommandableDeclarable, PropertyNames>(value, "level", This.PropertyValue.OPTIONAL_NOT_ADDED);
|
||||||
|
|
||||||
|
if (!This.PropertyType.isOptionalNullableNumber(Obj.getKey<DefaultableHeadingsCommandableDeclarable, PropertyNames>(value, "level")))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
isValid(declarable: HeadingsCommandableDeclarable) {
|
||||||
|
if (!CommandableAssistant.isValid(declarable))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!This.PropertyEq.optionalNullOrNumber(declarable.level))
|
||||||
|
return false;
|
||||||
|
|
||||||
// If `level` is not set, default to 1; if less than one it's invalid.
|
|
||||||
if (command.level === undefined || command.level === null) {
|
|
||||||
command.level = 1;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const This = HeadingsCommandableAssistant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDeclarable>
|
export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDeclarable>
|
||||||
extends CommandDeclarationParser<T> {
|
extends CommandDeclarationParser<T> {
|
||||||
|
|
||||||
|
|
@ -66,7 +98,7 @@ export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDec
|
||||||
for (let nextIndex = index + 1; nextIndex < delimiters.length; nextIndex++) {
|
for (let nextIndex = index + 1; nextIndex < delimiters.length; nextIndex++) {
|
||||||
const nextDelimiter = delimiters[nextIndex];
|
const nextDelimiter = delimiters[nextIndex];
|
||||||
if (nextDelimiter === undefined)
|
if (nextDelimiter === undefined)
|
||||||
throw new UnexpectedUndefinedError();
|
throw new UnexpectedUndefinedError();
|
||||||
if (HeadingsDeclarationParser.isHeadingCache(nextDelimiter) && headingLevel >= nextDelimiter.level)
|
if (HeadingsDeclarationParser.isHeadingCache(nextDelimiter) && headingLevel >= nextDelimiter.level)
|
||||||
return nextDelimiter;
|
return nextDelimiter;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/declarations/constants.ts
Normal file
17
src/declarations/constants.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
const LANGUAGE = "comethrough";
|
||||||
|
const LANGUAGE_SHORT = "ct";
|
||||||
|
|
||||||
|
export const SUPPORTED_CODEBLOCK_LANGUAGES = [LANGUAGE, LANGUAGE_SHORT];
|
||||||
|
|
||||||
|
export const DeclarationConstants = {
|
||||||
|
|
||||||
|
CodeBlock: {
|
||||||
|
LANGUAGES: SUPPORTED_CODEBLOCK_LANGUAGES,
|
||||||
|
isSupportedLanguage: (language: string) => SUPPORTED_CODEBLOCK_LANGUAGES.includes(language.toLowerCase()),
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
Frontmatter: {
|
||||||
|
KEYS: [LANGUAGE, LANGUAGE_SHORT, "come through"]
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
} as const;
|
||||||
33
src/env.ts
33
src/env.ts
|
|
@ -1,14 +1,14 @@
|
||||||
import { Platform } from "obsidian";
|
import { Platform } from "obsidian";
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env["NODE_ENV"] === "production";
|
||||||
const isDev = !isProduction;
|
const isDev = !isProduction;
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
const noopLogger = {
|
const noopLogger: Pick<Console, "debug" | "log" | "info" | "warn" | "error"> = {
|
||||||
debug: noop,
|
debug: () => {},
|
||||||
log: noop,
|
log: () => { },
|
||||||
info: noop,
|
info: () => {},
|
||||||
warn: noop,
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const devLogger = isDev ? console : noopLogger;
|
const devLogger = isDev ? console : noopLogger;
|
||||||
|
|
@ -22,7 +22,6 @@ export const Env = {
|
||||||
/** Debug/Dev context */
|
/** Debug/Dev context */
|
||||||
dev: isDev ? DevContext : undefined,
|
dev: isDev ? DevContext : undefined,
|
||||||
isDev: isDev,
|
isDev: isDev,
|
||||||
noop: noop,
|
|
||||||
|
|
||||||
/** Always logs */
|
/** Always logs */
|
||||||
error: console.error,
|
error: console.error,
|
||||||
|
|
@ -53,11 +52,12 @@ export const Env = {
|
||||||
e: console.error,
|
e: console.error,
|
||||||
|
|
||||||
/** Debug log for content processors. See {@link ContentRendererProcessor}. */
|
/** Debug log for content processors. See {@link ContentRendererProcessor}. */
|
||||||
proc: devLogger.info, // devLogger.info : noopLogger.debug,
|
proc: noopLogger.info,
|
||||||
|
|
||||||
/** Debug log for views. */
|
/** Debug log for views. */
|
||||||
view: devLogger.info, // devLogger.info : noopLogger.debug,
|
view: noopLogger.info,
|
||||||
|
|
||||||
|
data: noopLogger.info,
|
||||||
},
|
},
|
||||||
|
|
||||||
perf: {
|
perf: {
|
||||||
|
|
@ -78,12 +78,13 @@ export const Env = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** If running in mobile app that has very limited screen space. */
|
/** If running in mobile app that has very limited screen space. */
|
||||||
isPhone: Platform.isPhone,
|
get isPhone(): boolean {
|
||||||
|
return Platform.isPhone;
|
||||||
|
},
|
||||||
|
|
||||||
/** If running in mobile app that has sufficiently large screen space. */
|
/** If running in mobile app that has sufficiently large screen space. */
|
||||||
isTablet: Platform.isTablet,
|
get isTablet(): boolean {
|
||||||
|
return Platform.isTablet;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
str: {
|
|
||||||
EMPTY: "",
|
|
||||||
} as const
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
98
src/main.ts
98
src/main.ts
|
|
@ -1,23 +1,24 @@
|
||||||
import { OpenView, OpenViewCommand } from "commands/openView";
|
import { OpenView, OpenViewCommand } from "#/commands/openView";
|
||||||
import { ReviewViewCommand } from "commands/reviewView";
|
import { ReviewViewCommand } from "#/commands/reviewView";
|
||||||
import { DataStore, DataStoreRoot } from "data/DataStore";
|
import { DataStore, DataStoreRoot } from "#/data/DataStore";
|
||||||
import { SyncManager } from "data/SyncManager";
|
import { SyncManager } from "#/data/SyncManager";
|
||||||
import { UniqueID } from "data/UniqueID";
|
import { DeclarationManager } from "#/declarations/DeclarationManager";
|
||||||
import { DeclarationManager } from "declarations/DeclarationManager";
|
import { DeclarationParser } from "#/declarations/DeclarationParser";
|
||||||
import { DeclarationParser } from "declarations/DeclarationParser";
|
import { Env } from "#/env";
|
||||||
import { Env } from "env";
|
import t from "#/Localization";
|
||||||
import t from "Localization";
|
import { ConfirmationModal } from "#/modals/ConfirmationModal";
|
||||||
import { ConfirmationModal } from "modals/ConfirmationModal";
|
import { Scheduler } from "#/scheduling/Scheduler";
|
||||||
import { Editor, Keymap, MarkdownPostProcessorContext, MarkdownView, Plugin, TFile } from "obsidian";
|
import { FsrsSchedulerConfig } from "#/scheduling/types";
|
||||||
import { Scheduler } from "scheduling/Scheduler";
|
import { PluginSettings, SettingsChangedInfo, SettingsManager } from "#/Settings";
|
||||||
import { FsrsSchedulerConfig } from "scheduling/types";
|
import { Icon } from "#/ui/constants";
|
||||||
import { PluginSettings, SettingsChangedInfo, SettingsManager } from "Settings";
|
import { SettingTab } from "#/ui/SettingTab";
|
||||||
import { Icon } from "ui/constants";
|
import { UIAssistant } from "#/ui/UIAssistant";
|
||||||
import { SettingTab } from "ui/SettingTab";
|
import { DomState } from "#/utils/obs/DomState";
|
||||||
import { UIAssistant } from "ui/UIAssistant";
|
import { DecksView } from "#/views/DecksView";
|
||||||
import { DecksView } from "views/DecksView";
|
import { DefinedContentView } from "#/views/DefinedContentView";
|
||||||
import { DefinedContentView } from "views/DefinedContentView";
|
import { ReviewView } from "#/views/review/ReviewView";
|
||||||
import { ReviewView } from "views/review/ReviewView";
|
import { EditorCommand } from "commands/editor";
|
||||||
|
import { Keymap, MarkdownPostProcessorContext, Plugin, TFile } from "obsidian";
|
||||||
|
|
||||||
interface PluginData {
|
interface PluginData {
|
||||||
settings: PluginSettings;
|
settings: PluginSettings;
|
||||||
|
|
@ -25,16 +26,16 @@ interface PluginData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ComeThroughPlugin extends Plugin {
|
export default class ComeThroughPlugin extends Plugin {
|
||||||
private dataStore: DataStore;
|
private dataStore!: DataStore;
|
||||||
private scheduler: Scheduler;
|
private scheduler!: Scheduler;
|
||||||
private settingsManager: SettingsManager;
|
private settingsManager!: SettingsManager;
|
||||||
private syncManager: SyncManager;
|
private syncManager!: SyncManager;
|
||||||
private ui: UIAssistant;
|
private ui!: UIAssistant;
|
||||||
|
|
||||||
/** Must referene the latest data before {@link savePluginData} is called. Only needed to hold references in order to pass to {@link Plugin.saveData}, see {@link savePluginData}. */
|
/** Must reference the latest data before {@link savePluginData} is called. Only needed to hold references in order to pass to {@link Plugin.saveData}, see {@link savePluginData}. */
|
||||||
private latestPluginDataRef: PluginData;
|
private latestPluginDataRef!: PluginData;
|
||||||
|
|
||||||
public async onload() {
|
public override async onload() {
|
||||||
|
|
||||||
this.latestPluginDataRef = await ComeThroughPlugin.loadPluginData(this);
|
this.latestPluginDataRef = await ComeThroughPlugin.loadPluginData(this);
|
||||||
|
|
||||||
|
|
@ -44,8 +45,9 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
this.latestPluginDataRef.settings = settings;
|
this.latestPluginDataRef.settings = settings;
|
||||||
await this.savePluginData();
|
await this.savePluginData();
|
||||||
},
|
},
|
||||||
this.onSettingsSaved.bind(this)
|
this.onSettingsSaved
|
||||||
);
|
);
|
||||||
|
DomState.init(this, document);
|
||||||
|
|
||||||
this.dataStore = new DataStore(this.latestPluginDataRef.data, this.latestPluginDataRef.settings.removedItemsPurgeThreshold, async (data) => {
|
this.dataStore = new DataStore(this.latestPluginDataRef.data, this.latestPluginDataRef.settings.removedItemsPurgeThreshold, async (data) => {
|
||||||
this.latestPluginDataRef.data = data;
|
this.latestPluginDataRef.data = data;
|
||||||
|
|
@ -101,22 +103,19 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
this.addCommand(ReviewViewCommand.toggleInlineInfo(this.app));
|
this.addCommand(ReviewViewCommand.toggleInlineInfo(this.app));
|
||||||
this.addCommand(ReviewViewCommand.navigateToSourceFile(this.app));
|
this.addCommand(ReviewViewCommand.navigateToSourceFile(this.app));
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand(EditorCommand.generateId());
|
||||||
id: 'generate-id-cursor',
|
this.addCommand(EditorCommand.insertReviewUnit(false));
|
||||||
name: t.commands.generateId.name,
|
this.addCommand(EditorCommand.insertReviewUnit(true));
|
||||||
editorCallback: (editor: Editor, _view: MarkdownView) => {
|
|
||||||
editor.replaceRange(UniqueID.generateID(), editor.getCursor())
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onunload() {
|
public override onunload() {
|
||||||
if (this.confirmationModal)
|
DomState.deinit();
|
||||||
|
if (this.confirmationModal !== null)
|
||||||
this.confirmationModal.forceClose();
|
this.confirmationModal.forceClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This is triggered when the data file is modified by an external source, such as a sync service. */
|
/** This is triggered when the data file is modified by an external source, such as a sync service. */
|
||||||
public async onExternalSettingsChange() {
|
public override async onExternalSettingsChange() {
|
||||||
Env.log.d("Plugin:onExternalSettingsChange");
|
Env.log.d("Plugin:onExternalSettingsChange");
|
||||||
|
|
||||||
const overwrittenDataOnDisk = await ComeThroughPlugin.loadPluginData(this);
|
const overwrittenDataOnDisk = await ComeThroughPlugin.loadPluginData(this);
|
||||||
|
|
@ -152,9 +151,9 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
if (info.activeChanged || info.collectionsChanged) {
|
if (info.activeChanged || info.collectionsChanged) {
|
||||||
|
|
||||||
// Prevent multiple modals opening (as external changes might occur while the modal is showing).
|
// Prevent multiple modals opening (as external changes might occur while the modal is showing).
|
||||||
if (!this.confirmationModal) {
|
if (this.confirmationModal === null) {
|
||||||
this.confirmationModal = new ConfirmationModal(this.app);
|
this.confirmationModal = new ConfirmationModal(this.app);
|
||||||
this.confirmationModal.onClosed = () => this.confirmationModal = undefined;
|
this.confirmationModal.onClosed = () => this.confirmationModal = null;
|
||||||
this.confirmationModal.open();
|
this.confirmationModal.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,7 +171,7 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
private confirmationModal?: ConfirmationModal;
|
private confirmationModal: ConfirmationModal | null = null;
|
||||||
|
|
||||||
// public onUserEnable(): void {}
|
// public onUserEnable(): void {}
|
||||||
|
|
||||||
|
|
@ -182,10 +181,10 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
*/
|
*/
|
||||||
private registerEvents() {
|
private registerEvents() {
|
||||||
|
|
||||||
this.registerEvent(this.app.workspace.on("file-open", this.syncManager.open.bind(this.syncManager)));
|
this.registerEvent(this.app.workspace.on("file-open", this.syncManager.open));
|
||||||
this.registerEvent(this.app.metadataCache.on("changed", this.syncManager.changed.bind(this.syncManager)));
|
this.registerEvent(this.app.metadataCache.on("changed", this.syncManager.changed));
|
||||||
this.registerEvent(this.app.vault.on("delete", this.syncManager.delete.bind(this.syncManager)));
|
this.registerEvent(this.app.vault.on("delete", this.syncManager.delete));
|
||||||
this.registerEvent(this.app.vault.on("rename", this.syncManager.rename.bind(this.syncManager)));
|
this.registerEvent(this.app.vault.on("rename", this.syncManager.rename));
|
||||||
|
|
||||||
this.registerEvent(this.app.workspace.on("file-menu", (menu, file, source, _leaf) => {
|
this.registerEvent(this.app.workspace.on("file-menu", (menu, file, source, _leaf) => {
|
||||||
if (!(file instanceof TFile))
|
if (!(file instanceof TFile))
|
||||||
|
|
@ -236,17 +235,18 @@ export default class ComeThroughPlugin extends Plugin {
|
||||||
await this.saveData(this.latestPluginDataRef);
|
await this.saveData(this.latestPluginDataRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onSettingsSaved(changedInfo?: SettingsChangedInfo) {
|
private onSettingsSaved = (changedInfo?: SettingsChangedInfo) => {
|
||||||
switch (changedInfo) {
|
switch (changedInfo) {
|
||||||
case "schedulerConfig":
|
case "schedulerConfig":
|
||||||
this.scheduler.reconfigure(this.createSchedulerConfig());
|
this.scheduler.reconfigure(this.createSchedulerConfig());
|
||||||
break;
|
break;
|
||||||
|
case undefined:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
private createSchedulerConfig() {
|
private createSchedulerConfig() {
|
||||||
const config = this.settingsManager.defaultScheduler.config;
|
const config = this.settingsManager.defaultScheduler.config;
|
||||||
Env.assert(config);
|
|
||||||
return {
|
return {
|
||||||
enableFuzz: config.enableFuzz,
|
enableFuzz: config.enableFuzz,
|
||||||
} satisfies FsrsSchedulerConfig;
|
} satisfies FsrsSchedulerConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Env } from "env";
|
import { CssClass } from "#/constants";
|
||||||
|
import { Env } from "#/env";
|
||||||
|
import { CssClass as ObsCssClass } from "#/utils/obs/constants";
|
||||||
import { App, Modal, Setting } from "obsidian";
|
import { App, Modal, Setting } from "obsidian";
|
||||||
import { CssClass } from "utils/obs/constants";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - Adds a plugin specific class to all modals for styling purposes.
|
* - Adds a plugin specific class to all modals for styling purposes.
|
||||||
|
|
@ -29,15 +30,15 @@ export class BaseModal extends Modal {
|
||||||
// "Show debug info" modal has this class which makes the height 100% on mobile, i.e. fullscreen.
|
// "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 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)
|
if (options?.fullscreenOnLimitedScreenSpace && Env.isPhone)
|
||||||
this.modalEl.addClass(CssClass.Modal.LG);
|
this.modalEl.addClass(ObsCssClass.Modal.LG);
|
||||||
|
|
||||||
this.contentEl.addClass("come-through-modal-content");
|
this.contentEl.addClass(CssClass.Modal.CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createSetting(name: string, o?: { styleControlElAsDesc?: boolean, isHeading?: boolean }) {
|
protected createSetting(name: string, o?: { styleControlElAsDesc?: boolean, isHeading?: boolean }) {
|
||||||
const s = new Setting(this.contentEl).setName(name)
|
const s = new Setting(this.contentEl).setName(name)
|
||||||
if (o?.styleControlElAsDesc)
|
if (o?.styleControlElAsDesc)
|
||||||
s.controlEl.addClass(CssClass.Setting.Item.DESC);
|
s.controlEl.addClass(ObsCssClass.Setting.Item.DESC);
|
||||||
if (o?.isHeading)
|
if (o?.isHeading)
|
||||||
s.setHeading();
|
s.setHeading();
|
||||||
return s;
|
return s;
|
||||||
|
|
@ -46,9 +47,9 @@ export class BaseModal extends Modal {
|
||||||
/** Using this you get better bottom margins on mobile than adding buttons on a `Setting`. */
|
/** Using this you get better bottom margins on mobile than adding buttons on a `Setting`. */
|
||||||
protected getButtonContainer() {
|
protected getButtonContainer() {
|
||||||
// Try to find existing button container, or create one if it doesn't exist
|
// Try to find existing button container, or create one if it doesn't exist
|
||||||
let buttonContainer = this.modalEl.querySelector<HTMLElement>("." + CssClass.Modal.BUTTON_CONTAINER);
|
let buttonContainer = this.modalEl.querySelector<HTMLElement>("." + ObsCssClass.Modal.BUTTON_CONTAINER);
|
||||||
if (buttonContainer === null)
|
if (buttonContainer === null)
|
||||||
buttonContainer = this.contentEl.createDiv(CssClass.Modal.BUTTON_CONTAINER);
|
buttonContainer = this.contentEl.createDiv(ObsCssClass.Modal.BUTTON_CONTAINER);
|
||||||
return buttonContainer;
|
return buttonContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ export class BaseModal extends Modal {
|
||||||
|
|
||||||
this.getButtonContainer().createEl("button", {
|
this.getButtonContainer().createEl("button", {
|
||||||
text: "Done",
|
text: "Done",
|
||||||
cls: CssClass.Modal.CANCEL,
|
cls: ObsCssClass.Modal.CANCEL,
|
||||||
}, (button => {
|
}, (button => {
|
||||||
button.addEventListener("click", () => this.close());
|
button.addEventListener("click", () => this.close());
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,132 @@
|
||||||
import { DataStore, DeckEditor, DeckIDDataTuple } from "data/DataStore";
|
import { DataStore, DeckEditor, DeckIDDataTuple } from "#/data/DataStore";
|
||||||
import { DeckID } from "data/FullID";
|
import { DeckID } from "#/data/FullID";
|
||||||
import { BaseModal } from "modals/BaseModal";
|
import { BaseModal } from "#/modals/BaseModal";
|
||||||
|
import { HtmlTag } from "#/utils/dom/constants";
|
||||||
|
import { Str } from "#/utils/ts";
|
||||||
import { App, ButtonComponent, Setting } from "obsidian";
|
import { App, ButtonComponent, Setting } from "obsidian";
|
||||||
import { UIAssistant } from "ui/UIAssistant";
|
|
||||||
|
|
||||||
|
export type OnSubmitCallback = (deck: DeckIDDataTuple) => void;
|
||||||
|
|
||||||
export class DeckModal extends BaseModal {
|
export class DeckModal extends BaseModal {
|
||||||
|
|
||||||
private nameOfDeck: string = "";
|
private nameOfDeck: string = Str.EMPTY;
|
||||||
private parentDeckID: DeckID | null;
|
private parentDeckID: DeckID | null = null;
|
||||||
private createButton: ButtonComponent;
|
private createButton!: ButtonComponent;
|
||||||
private result?: DeckIDDataTuple;
|
private result: DeckIDDataTuple | undefined;
|
||||||
|
|
||||||
public static add(app: App,
|
private readonly data: DataStore;
|
||||||
data: DataStore,
|
private readonly onSubmit: OnSubmitCallback | undefined;
|
||||||
onAdded: (deck: DeckIDDataTuple) => void) {
|
private readonly idToEdit: DeckID | undefined;
|
||||||
new DeckModal(app, data, onAdded).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
public static add(
|
||||||
readonly app: App,
|
app: App,
|
||||||
private readonly data: DataStore,
|
data: DataStore,
|
||||||
private readonly onSubmit: (deck: DeckIDDataTuple) => void,
|
onAdded?: OnSubmitCallback) {
|
||||||
private readonly idToEdit?: DeckID) {
|
new DeckModal(app, data, onAdded).open();
|
||||||
super(app, { fullscreenOnLimitedScreenSpace: true });
|
}
|
||||||
|
|
||||||
if (idToEdit) {
|
public static edit(
|
||||||
const deckToEdit = data.getDeck(idToEdit, true)!;
|
app: App,
|
||||||
this.nameOfDeck = deckToEdit.n;
|
data: DataStore,
|
||||||
this.parentDeckID = DeckEditor.parent(deckToEdit);
|
id: DeckID,
|
||||||
this.setTitle("Edit deck");
|
onSubmit?: OnSubmitCallback) {
|
||||||
}
|
new DeckModal(app, data, onSubmit, id).open();
|
||||||
else {
|
}
|
||||||
this.setTitle("Create a new deck");
|
|
||||||
}
|
|
||||||
|
|
||||||
new Setting(this.contentEl)
|
private constructor(
|
||||||
.setName("Name")
|
app: App,
|
||||||
.addText((component) => {
|
data: DataStore,
|
||||||
component.setValue(this.nameOfDeck);
|
onSubmit?: OnSubmitCallback,
|
||||||
component.onChange((text) => {
|
idToEdit?: DeckID
|
||||||
this.nameOfDeck = text;
|
) {
|
||||||
this.createButton.setDisabled(this.nameOfDeck.trim().length == 0);
|
super(app, { fullscreenOnLimitedScreenSpace: true });
|
||||||
});
|
this.data = data;
|
||||||
});
|
this.onSubmit = onSubmit;
|
||||||
|
this.idToEdit = idToEdit;
|
||||||
|
|
||||||
new Setting(this.contentEl)
|
if (idToEdit) {
|
||||||
.setName("Parent deck")
|
const deckToEdit = data.getDeck(idToEdit, true)!;
|
||||||
.setDesc(idToEdit ? "" : "To make this deck a subdeck, choose a parent deck.")
|
this.nameOfDeck = deckToEdit.n;
|
||||||
.addDropdown((component) => {
|
this.parentDeckID = DeckEditor.parent(deckToEdit);
|
||||||
|
this.setTitle("Edit deck");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.setTitle("Create a new deck");
|
||||||
|
}
|
||||||
|
|
||||||
component.addOption(UIAssistant.DECK_ID_NONE, "None");
|
new Setting(this.contentEl)
|
||||||
for (const deck of this.data.getAllDecks().filter(d => d.id !== this.idToEdit))
|
.setName("Name")
|
||||||
component.addOption(deck.id, deck.data.n);
|
.addText((component) => {
|
||||||
component.setValue(this.parentDeckID ?? UIAssistant.DECK_ID_NONE);
|
component.setValue(this.nameOfDeck);
|
||||||
|
component.onChange((text) => {
|
||||||
|
this.nameOfDeck = text;
|
||||||
|
this.createButton.setDisabled(this.nameOfDeck.trim().length == 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
component.onChange((value) => {
|
new Setting(this.contentEl)
|
||||||
this.parentDeckID = value === UIAssistant.DECK_ID_NONE ? null : value;
|
.setName("Parent deck")
|
||||||
});
|
.setDesc(idToEdit ? "" : "To make this deck a subdeck, choose a parent deck.")
|
||||||
});
|
.addDropdown((component) => {
|
||||||
|
|
||||||
new Setting(this.contentEl)
|
component.addOption(HtmlTag.SELECT.OPTION.Values.NONE, "None");
|
||||||
.addButton((button) => {
|
for (const deck of this.data.getAllDecks().filter(d => d.id !== this.idToEdit))
|
||||||
this.createButton = button;
|
component.addOption(deck.id, deck.data.n);
|
||||||
button.setDisabled(this.nameOfDeck.trim().length == 0);
|
component.setValue(this.parentDeckID ?? HtmlTag.SELECT.OPTION.Values.NONE);
|
||||||
button.setCta()
|
|
||||||
button.setButtonText(this.idToEdit ? "Save" : "Create new deck");
|
|
||||||
button.onClick(async () => {
|
|
||||||
button.setDisabled(true);
|
|
||||||
await this.submit();
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.addButton((button) => {
|
|
||||||
button.setButtonText("Cancel");
|
|
||||||
button.onClick(() => {
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// onOpen(): void {
|
component.onChange((value) => {
|
||||||
// super.onOpen();
|
this.parentDeckID = HtmlTag.SELECT.OPTION.isNone(value) ? null : value;
|
||||||
// }
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onClose(): void {
|
new Setting(this.contentEl)
|
||||||
super.onClose();
|
.addButton((button) => {
|
||||||
|
this.createButton = button;
|
||||||
|
button.setDisabled(this.nameOfDeck.trim().length == 0);
|
||||||
|
button.setCta()
|
||||||
|
button.setButtonText(Str.isNonEmpty(this.idToEdit) ? "Save" : "Create new deck");
|
||||||
|
button.onClick(async () => {
|
||||||
|
button.setDisabled(true);
|
||||||
|
await this.submit();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addButton((button) => {
|
||||||
|
button.setButtonText("Cancel");
|
||||||
|
button.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (this.result) {
|
public override onClose(): void {
|
||||||
const result = this.result;
|
super.onClose();
|
||||||
setTimeout(() => this.onSubmit(result), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async submit() {
|
if (this.onSubmit !== undefined && this.result !== undefined) {
|
||||||
const cb = (editor: DeckEditor) => {
|
const result = this.result;
|
||||||
editor.setName(this.nameOfDeck);
|
const onSubmit = this.onSubmit;
|
||||||
editor.setParent(this.parentDeckID);
|
setTimeout(() => onSubmit(result), 1);
|
||||||
return true;
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let deckID: DeckID;
|
private async submit() {
|
||||||
if (this.idToEdit) {
|
const cb = (editor: DeckEditor) => {
|
||||||
deckID = this.idToEdit;
|
editor.setName(this.nameOfDeck);
|
||||||
this.data.editDeck(this.idToEdit, cb);
|
editor.setParent(this.parentDeckID);
|
||||||
}
|
return true;
|
||||||
else {
|
};
|
||||||
deckID = this.data.createDeck(cb).id;
|
|
||||||
}
|
|
||||||
await this.data.save();
|
|
||||||
|
|
||||||
const deck = this.data.getDeck(deckID, true);
|
let deckID: DeckID;
|
||||||
this.result = { id: deckID, data: deck! };
|
if (this.idToEdit !== undefined) {
|
||||||
}
|
deckID = this.idToEdit;
|
||||||
|
const edited = this.data.editDeck(this.idToEdit, cb);
|
||||||
|
if (edited !== null)
|
||||||
|
this.result = { id: deckID, data: edited };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const created = this.data.createDeck(cb);
|
||||||
|
this.result = { id: created.id, data: created.data };
|
||||||
|
}
|
||||||
|
await this.data.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ export class ReviewItemInfoModal extends BaseModal {
|
||||||
.setDesc("The number of review units left in the current review queue.");
|
.setDesc("The number of review units left in the current review queue.");
|
||||||
|
|
||||||
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
||||||
entries.forEach(([date, value], index) => {
|
entries.forEach(([date, value], _index) => {
|
||||||
let timeString: string;
|
let timeString: string;
|
||||||
const hours = date.getHours();
|
const hours = date.getHours();
|
||||||
const minutes = date.getMinutes();
|
const minutes = date.getMinutes();
|
||||||
|
|
@ -169,7 +169,7 @@ export class ReviewItemInfoModal extends BaseModal {
|
||||||
.setDesc("The number of review units left in the current review queue below the given retrievability.");
|
.setDesc("The number of review units left in the current review queue below the given retrievability.");
|
||||||
|
|
||||||
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
||||||
entries.forEach(([threshold, value], index) => {
|
entries.forEach(([threshold, value], _index) => {
|
||||||
rowCreator.add((col) => {
|
rowCreator.add((col) => {
|
||||||
col.add({ text: `Below ${threshold * 100}%:` });
|
col.add({ text: `Below ${threshold * 100}%:` });
|
||||||
col.add({ text: `${Str.NON_BREAKING_SPACE}${value}` });
|
col.add({ text: `${Str.NON_BREAKING_SPACE}${value}` });
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,43 @@
|
||||||
import { DataStore, DeckIDDataTuple } from "data/DataStore";
|
import { DataStore, DeckIDDataTuple } from "#/data/DataStore";
|
||||||
|
import { HtmlTag } from "#/utils/dom/constants";
|
||||||
import { App, SuggestModal } from "obsidian";
|
import { App, SuggestModal } from "obsidian";
|
||||||
import { UIAssistant } from "ui/UIAssistant";
|
|
||||||
|
|
||||||
|
const ALL_DECKS = {
|
||||||
|
id: HtmlTag.SELECT.OPTION.Values.NONE,
|
||||||
|
data: {
|
||||||
|
n: "All Decks",
|
||||||
|
p: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAllDecks(deck: DeckIDDataTuple): boolean {
|
||||||
|
return HtmlTag.SELECT.OPTION.isNone(deck.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `null` value indicates that the user selected "All Decks".
|
||||||
|
*/
|
||||||
|
export type OnChooseCallback = (deck: DeckIDDataTuple | null, evt: MouseEvent | KeyboardEvent) => void;
|
||||||
|
|
||||||
export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
|
export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
|
||||||
|
|
||||||
private decks: DeckIDDataTuple[];
|
private decks: DeckIDDataTuple[];
|
||||||
|
|
||||||
|
private readonly onChoose: OnChooseCallback | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param decks If you already have all decks, or if you want to display a subset.
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
private readonly data: DataStore,
|
private readonly data: DataStore,
|
||||||
decks?: DeckIDDataTuple[],
|
decks?: DeckIDDataTuple[],
|
||||||
private readonly onChoose?: (deck: DeckIDDataTuple, evt: MouseEvent | KeyboardEvent) => void) {
|
onChoose?: OnChooseCallback) {
|
||||||
super(app);
|
super(app);
|
||||||
|
|
||||||
this.setPlaceholder("Select deck");
|
this.setPlaceholder("Select deck");
|
||||||
this.decks = decks ?? this.data.getAllDecks();
|
this.decks = [...[ALL_DECKS], ...decks ?? this.data.getAllDecks()];
|
||||||
|
this.onChoose = onChoose;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestions(query: string): DeckIDDataTuple[] | Promise<DeckIDDataTuple[]> {
|
getSuggestions(query: string): DeckIDDataTuple[] | Promise<DeckIDDataTuple[]> {
|
||||||
|
|
@ -23,13 +45,13 @@ export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSuggestion(value: DeckIDDataTuple, el: HTMLElement): void {
|
renderSuggestion(value: DeckIDDataTuple, el: HTMLElement): void {
|
||||||
const numberOfCards = this.data.getAllCardsForDeck(value.id === UIAssistant.DECK_ID_NONE ? undefined : value.id).length;
|
const numberOfCards = this.data.getAllCardsForDeck(isAllDecks(value) ? undefined : value.id).length;
|
||||||
el.createEl('div', { text: `${value.data.n}` }).createEl('small', { text: ` (${numberOfCards})` });
|
el.createEl('div', { text: `${value.data.n}` }).createEl('small', { text: ` (${numberOfCards})` });
|
||||||
el.createEl('small', { text: value.data.p.length > 0 ? this.descendants(value) : "" });
|
el.createEl('small', { text: value.data.p.length > 0 ? this.descendants(value) : "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
onChooseSuggestion(item: DeckIDDataTuple, evt: MouseEvent | KeyboardEvent): void {
|
onChooseSuggestion(item: DeckIDDataTuple, evt: MouseEvent | KeyboardEvent): void {
|
||||||
this.onChoose?.(item, evt);
|
this.onChoose?.(isAllDecks(item) ? null : item, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private descendants(deck: DeckIDDataTuple): string {
|
private descendants(deck: DeckIDDataTuple): string {
|
||||||
|
|
|
||||||
32
src/renderings/RendererRegistry.ts
Normal file
32
src/renderings/RendererRegistry.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CommandableAssistant } from "#/declarations/Commandable";
|
||||||
|
import { Commands } from "#/declarations/CommandNames";
|
||||||
|
import { ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { AlternateHeadingsRenderer } from "#/renderings/declarations/AlternateHeadingsRenderer";
|
||||||
|
import { DeclarationRenderable, DeclarationRenderer, FactoryRegistryEntry } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
import { HeadingAndDelimiterRenderer } from "#/renderings/declarations/HeadingAndDelimiterRenderer";
|
||||||
|
import { HeadingIsFrontRenderer } from "#/renderings/declarations/HeadingIsFrontRenderer";
|
||||||
|
import { PageDeclarationRenderer } from "#/renderings/declarations/PageDeclarationRenderer";
|
||||||
|
|
||||||
|
|
||||||
|
const COMMAND_RENDERERS: FactoryRegistryEntry[] = [
|
||||||
|
DeclarationRenderer.Factory.createEntry(Commands.Name.AlternateHeadings, AlternateHeadingsRenderer),
|
||||||
|
DeclarationRenderer.Factory.createEntry(Commands.Name.HeadingAndDelimiter, HeadingAndDelimiterRenderer),
|
||||||
|
DeclarationRenderer.Factory.createEntry(Commands.Name.HeadingIsFront, HeadingIsFrontRenderer),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RendererRegistry = {
|
||||||
|
tryCreate(declaration: unknown): DeclarationRenderable | null {
|
||||||
|
|
||||||
|
let r: DeclarationRenderable | null = null;
|
||||||
|
|
||||||
|
if (CommandableAssistant.is(declaration)) {
|
||||||
|
const entry = COMMAND_RENDERERS.find(r => r.names.includes(declaration.name));
|
||||||
|
r = entry ? entry.create(declaration) : null
|
||||||
|
}
|
||||||
|
else if (ExplicitDeclarationAssistant.MaybePage.is(declaration)) {
|
||||||
|
r = new PageDeclarationRenderer(declaration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -73,7 +73,7 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = this.registeredAudioItems.find(p => p.el === audioElThatStartedPlaying) ?? null;
|
const match = this.registeredAudioItems.find(p => p.el === audioElThatStartedPlaying) ?? null;
|
||||||
Env.assert(match, "Expected to find the element.")
|
Env.assert(match !== null, "Expected to find the element.")
|
||||||
this.currentlyPlaying = match;
|
this.currentlyPlaying = match;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,12 +89,12 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
|
||||||
/**
|
/**
|
||||||
* - Note: If paused because of {@link HtmlAttribute.MediaElement.Plugin.Data.LoopDelay} it is still considered playing.
|
* - Note: If paused because of {@link HtmlAttribute.MediaElement.Plugin.Data.LoopDelay} it is still considered playing.
|
||||||
*/
|
*/
|
||||||
private currentlyPlaying: { el: HTMLAudioElement, controller: TimeLoopController } | null;
|
private currentlyPlaying: { el: HTMLAudioElement, controller: TimeLoopController } | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns `null` if no source was set.
|
* @returns `null` if no source was set.
|
||||||
*/
|
*/
|
||||||
private static getPlayerInfo(param: PostProcessorParameter, el: HTMLAudioElement): AudioPlayerInfo | null {
|
private static getPlayerInfo(_param: PostProcessorParameter, el: HTMLAudioElement): AudioPlayerInfo | null {
|
||||||
Env.log.proc(`getPlayerInfo`);
|
Env.log.proc(`getPlayerInfo`);
|
||||||
|
|
||||||
const getSource = () => {
|
const getSource = () => {
|
||||||
|
|
@ -113,7 +113,7 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
|
||||||
return "current";
|
return "current";
|
||||||
|
|
||||||
case HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.Values.INITIAL:
|
case HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.Values.INITIAL:
|
||||||
default:
|
case null:
|
||||||
return "init";
|
return "init";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -465,7 +465,7 @@ class TimeLoopController extends Component {
|
||||||
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime}.`);
|
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime}.`);
|
||||||
this.seekTo(player, this.info.startTime);
|
this.seekTo(player, this.info.startTime);
|
||||||
break;
|
break;
|
||||||
default:
|
case "current":
|
||||||
Env.log.proc(`\t\tRetaining playback position`);
|
Env.log.proc(`\t\tRetaining playback position`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { RecycleComponent } from "utils/obs/RecycleComponent";
|
||||||
import { PluginSettings } from "Settings";
|
import { PluginSettings } from "Settings";
|
||||||
import { Async } from "utils/ts";
|
import { Async } from "utils/ts";
|
||||||
|
|
||||||
export function createRenderConfig(settings: PluginSettings): ContentProcessorConfig {
|
export function createRenderConfig(_settings: PluginSettings): ContentProcessorConfig {
|
||||||
return {
|
return {
|
||||||
media: {
|
media: {
|
||||||
preventMultiplePlayback: true,
|
preventMultiplePlayback: true,
|
||||||
|
|
@ -45,7 +45,7 @@ export class ContentRenderer extends RecycleComponent {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onRecycled(component: Component): void {
|
protected override onRecycled(component: Component): void {
|
||||||
Env.log.d("ContentRenderer:onRecycled");
|
Env.log.d("ContentRenderer:onRecycled");
|
||||||
|
|
||||||
/** These will always run, and before any custom ones. */
|
/** These will always run, and before any custom ones. */
|
||||||
|
|
@ -59,7 +59,7 @@ export class ContentRenderer extends RecycleComponent {
|
||||||
this.defaultProcessors.forEach(p => component.addChild(p));
|
this.defaultProcessors.forEach(p => component.addChild(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onRecycling(): void {
|
protected override onRecycling(): void {
|
||||||
Env.log.d("ContentRenderer:onRecycling");
|
Env.log.d("ContentRenderer:onRecycling");
|
||||||
this.defaultProcessors = [];
|
this.defaultProcessors = [];
|
||||||
this.customProcessors = [];
|
this.customProcessors = [];
|
||||||
|
|
@ -112,8 +112,7 @@ export class ContentRenderer extends RecycleComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processors)
|
processors.forEach(p => this.recycleComponent.addChild(p));
|
||||||
processors.forEach(p => this.recycleComponent.addChild(p));
|
|
||||||
|
|
||||||
const param = {
|
const param = {
|
||||||
app: this.app,
|
app: this.app,
|
||||||
|
|
@ -151,7 +150,6 @@ export class ContentRenderer extends RecycleComponent {
|
||||||
processor.handleHtml(postParameter);
|
processor.handleHtml(postParameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processors)
|
processors.forEach(p => this.recycleComponent.removeChild(p));
|
||||||
processors.forEach(p => this.recycleComponent.removeChild(p));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Env } from "env";
|
import { Env } from "env";
|
||||||
import { App, Component } from "obsidian";
|
import { App, Component } from "obsidian";
|
||||||
import { getDoc } from "utils/obs/dom";
|
import { Doc } from "utils/dom/dom";
|
||||||
|
|
||||||
export type MediaContentProcessorConfig = {
|
export type MediaContentProcessorConfig = {
|
||||||
preventMultiplePlayback: boolean;
|
preventMultiplePlayback: boolean;
|
||||||
|
|
@ -74,7 +74,7 @@ export class ContentRendererPostProcessorAssistant {
|
||||||
}
|
}
|
||||||
|
|
||||||
public get doc() {
|
public get doc() {
|
||||||
return getDoc(this.param.el);
|
return Doc.get(this.param.el);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get el() {
|
public get el() {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export class VideoProcessor extends ContentRendererProcessor implements ContentR
|
||||||
param.el.querySelectorAll("video").forEach(el => this.handleVideo(param, el));
|
param.el.querySelectorAll("video").forEach(el => this.handleVideo(param, el));
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleVideo(param: PostProcessorParameter, el: HTMLVideoElement) {
|
private handleVideo(_param: PostProcessorParameter, el: HTMLVideoElement) {
|
||||||
this.setDefaults(el);
|
this.setDefaults(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
|
import { AlternateHeadingsAssistant, AlternateHeadingsDeclarable } from "#/declarations/commands/AlternateHeadings";
|
||||||
import { AlternateHeadingsDeclarable, AlternateHeadingsAssistant } from "declarations/commands/AlternateHeadings";
|
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
|
||||||
|
const Assistant = AlternateHeadingsAssistant;
|
||||||
|
|
||||||
export class AlternateHeadingsRenderer
|
export class AlternateHeadingsRenderer
|
||||||
extends DeclarationRenderer<AlternateHeadingsDeclarable>
|
extends DeclarationRenderer<AlternateHeadingsDeclarable>
|
||||||
implements DeclarationRenderable {
|
implements DeclarationRenderable {
|
||||||
|
|
||||||
|
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||||
|
|
||||||
public render(r: DeclarationRenderAssistant) {
|
public render(r: DeclarationRenderAssistant) {
|
||||||
|
|
||||||
if (!AlternateHeadingsAssistant.conforms(this.declarable) || !AlternateHeadingsAssistant.isValid(this.declarable)) {
|
if (!Assistant.isValid(this.declarable)) {
|
||||||
r.setError();
|
r.setError();
|
||||||
r.setTitle("Invalid alternate headings command");
|
r.setTitle("Invalid alternate headings command");
|
||||||
r.addParagraph("Please check the entered values.");
|
r.addParagraph("Please check the entered values.");
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
|
|
||||||
import { CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
|
|
||||||
|
|
||||||
export class CardDeclarationRenderer
|
|
||||||
extends DeclarationRenderer<DefaultableCardDeclarable>
|
|
||||||
implements DeclarationRenderable {
|
|
||||||
|
|
||||||
public render(f: DeclarationRenderAssistant) {
|
|
||||||
|
|
||||||
f.setTitle("Flashcard declaration");
|
|
||||||
|
|
||||||
const table = f.createEl("table");
|
|
||||||
const body = table.createEl("tbody");
|
|
||||||
|
|
||||||
const rowID = body.createEl("tr");
|
|
||||||
rowID.createEl("td", { text: "ID" });
|
|
||||||
rowID.createEl("td", {
|
|
||||||
text: CardDeclarationAssistant.conformsToDeclarable(this.declarable) ? this.declarable.id : ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowSide = body.createEl("tr");
|
|
||||||
rowSide.createEl("td", { text: "Side" });
|
|
||||||
rowSide.createEl("td", { text: `${CardDeclarationAssistant.isFrontSide(this.declarable) ? "Front" : "Back"}` });
|
|
||||||
|
|
||||||
if (CardDeclarationAssistant.isFrontSide(this.declarable)) {
|
|
||||||
f.createDeckRow(body, this.declarable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/declarations/DeclarationRenderable";
|
import { DeclarationRenderable, DeclarationRenderAssistant } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
import { Declarable } from "declarations/Declaration";
|
import { YamlObject } from "#/declarations/DeclarationCodec";
|
||||||
|
|
||||||
|
|
||||||
export class DeclarationErrorRenderer
|
export class DeclarationErrorRenderer
|
||||||
extends DeclarationRenderer<Declarable>
|
|
||||||
implements DeclarationRenderable {
|
implements DeclarationRenderable {
|
||||||
|
|
||||||
public method?: (r: DeclarationRenderAssistant, errorMessage?: string) => void;
|
public method?: (r: DeclarationRenderAssistant, errorMessage?: string) => void;
|
||||||
public errorMessage?: string;
|
public errorMessage?: string;
|
||||||
|
|
||||||
|
private declaration: YamlObject;
|
||||||
|
|
||||||
|
constructor(declaration: YamlObject) {
|
||||||
|
this.declaration = declaration;
|
||||||
|
}
|
||||||
|
|
||||||
public render(r: DeclarationRenderAssistant) {
|
public render(r: DeclarationRenderAssistant) {
|
||||||
r.setError();
|
r.setError();
|
||||||
if (this.method) {
|
if (this.method) {
|
||||||
|
|
@ -20,12 +25,12 @@ export class DeclarationErrorRenderer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static invalidCardDeclaration(r: DeclarationRenderAssistant, errorMessage?: string) {
|
public static invalidCardDeclaration(r: DeclarationRenderAssistant, _errorMessage?: string) {
|
||||||
r.setTitle("Invalid card declaration");
|
r.setTitle("Invalid card declaration");
|
||||||
r.addParagraph("Please check entered keys and values.");
|
r.addParagraph("Please check entered keys and values.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static invalidCommandDeclaration(r: DeclarationRenderAssistant, errorMessage?: string) {
|
public static invalidCommandDeclaration(r: DeclarationRenderAssistant, _errorMessage?: string) {
|
||||||
r.setTitle("Invalid declaration command");
|
r.setTitle("Invalid declaration command");
|
||||||
r.addParagraph("Please check the entered values.");
|
r.addParagraph("Please check the entered values.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import { CardDeclarationAssistant } from "declarations/CardDeclaration";
|
import { CommandableAssistant } from "#/declarations/Commandable";
|
||||||
import { CommandDeclarationAssistant } from "declarations/CommandDeclaration";
|
import { DeclarableAssistant } from "#/declarations/Declarable";
|
||||||
import { Declaration } from "declarations/Declaration";
|
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
|
||||||
import { Env } from "env";
|
import { Env } from "#/env";
|
||||||
|
import { DeclarationErrorRenderer } from "#/renderings/declarations/DeclarationErrorRenderer";
|
||||||
|
import { DataProvider, DeclarationChangedEvent, DeclarationRenderAssistant } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
import { RendererRegistry } from "#/renderings/RendererRegistry";
|
||||||
|
import { Icon } from "#/ui/constants";
|
||||||
import { MarkdownRenderChild, setIcon } from "obsidian";
|
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 {
|
export class DeclarationRenderChild extends MarkdownRenderChild {
|
||||||
|
|
||||||
|
private source: string;
|
||||||
|
private dataProvider: DataProvider;
|
||||||
|
|
||||||
|
private titleContainer!: HTMLDivElement;
|
||||||
|
private titleEl!: HTMLDivElement;
|
||||||
|
private contentContainerEl!: HTMLDivElement;
|
||||||
|
|
||||||
public constructor(containerEl: HTMLElement, source: string, dataProvider: DataProvider) {
|
public constructor(containerEl: HTMLElement, source: string, dataProvider: DataProvider) {
|
||||||
super(containerEl);
|
super(containerEl);
|
||||||
|
|
||||||
|
|
@ -20,25 +24,31 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
|
||||||
this.dataProvider = dataProvider;
|
this.dataProvider = dataProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override onload(): void {
|
||||||
|
Env.log.d("DeclarationRenderChild:onload");
|
||||||
|
super.onload();
|
||||||
|
|
||||||
|
// Manual cleanup for these elements in `onunload` is not necessary because they are added directly
|
||||||
|
// to `containerEl`, which is managed and eventually discarded by the base class.
|
||||||
|
this.containerEl.addClass("callout");
|
||||||
|
|
||||||
|
this.titleContainer = this.containerEl.createDiv({ cls: "callout-title" });
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
public override onunload(): void {
|
public override onunload(): void {
|
||||||
Env.log.d("DeclarationRenderChild:onunload");
|
Env.log.d("DeclarationRenderChild:onunload");
|
||||||
super.onunload();
|
super.onunload();
|
||||||
}
|
}
|
||||||
|
|
||||||
private contentContainerEl: HTMLDivElement;
|
|
||||||
private source: string;
|
|
||||||
private dataProvider: DataProvider;
|
|
||||||
|
|
||||||
private titleContainer: HTMLDivElement;
|
|
||||||
private titleEl: HTMLDivElement;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param onDomEvent DOM event registered with rendered elements such as buttons or select.
|
* @param onDomEvent DOM event registered with rendered elements such as buttons or select.
|
||||||
*/
|
*/
|
||||||
public render(onDomEvent: DeclarationChangedEvent) {
|
public render(onDomEvent: DeclarationChangedEvent) {
|
||||||
|
|
||||||
this.initRender();
|
|
||||||
|
|
||||||
const r = new DeclarationRenderAssistant(
|
const r = new DeclarationRenderAssistant(
|
||||||
this.containerEl,
|
this.containerEl,
|
||||||
this.contentContainerEl,
|
this.contentContainerEl,
|
||||||
|
|
@ -49,50 +59,28 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
|
||||||
onDomEvent
|
onDomEvent
|
||||||
);
|
);
|
||||||
|
|
||||||
const declaration = Declaration.tryParseYaml(this.source, error => this.renderYamlError(r, error.message));
|
const declaration = DeclarationCodec.tryFromYaml(this.source, error => this.renderYamlError(r, error.message));
|
||||||
if (declaration === null)
|
if (declaration === null || !DeclarableAssistant.is(declaration))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let declarationRenderer: DeclarationRenderable = new DeclarationErrorRenderer(declaration);
|
let declarationRenderer = RendererRegistry.tryCreate(declaration);
|
||||||
|
|
||||||
if (CommandDeclarationAssistant.conforms(declaration)) {
|
if (declarationRenderer === null) {
|
||||||
|
const errorRenderer = new DeclarationErrorRenderer(declaration);
|
||||||
|
|
||||||
if (CommandDeclarationAssistant.isNameValid(declaration)) {
|
if (CommandableAssistant.is(declaration)) {
|
||||||
if (CommandDeclarationAssistant.isHeadingAndDelimiter(declaration))
|
errorRenderer.method = DeclarationErrorRenderer.unknownCommandName;
|
||||||
declarationRenderer = new HeadingAndDelimiterRenderer(declaration);
|
errorRenderer.errorMessage = `Name: ${declaration.name}`;
|
||||||
else if (CommandDeclarationAssistant.isAlternateHeadings(declaration))
|
} else {
|
||||||
declarationRenderer = new AlternateHeadingsRenderer(declaration);
|
errorRenderer.method = DeclarationErrorRenderer.invalidCardDeclaration;
|
||||||
else if (CommandDeclarationAssistant.isHeadingIsFront(declaration))
|
|
||||||
declarationRenderer = new HeadingIsFrontRenderer(declaration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (declarationRenderer instanceof DeclarationErrorRenderer) {
|
declarationRenderer = errorRenderer;
|
||||||
declarationRenderer.method = DeclarationErrorRenderer.unknownCommandName;
|
|
||||||
declarationRenderer.errorMessage = `Name: ${declaration.name}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (CardDeclarationAssistant.conformsToDefaultable(declaration))
|
|
||||||
declarationRenderer = new CardDeclarationRenderer(declaration);
|
|
||||||
|
|
||||||
if (declarationRenderer instanceof DeclarationErrorRenderer)
|
|
||||||
declarationRenderer.method = DeclarationErrorRenderer.invalidCardDeclaration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declarationRenderer.render(r);
|
declarationRenderer.render(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRender() {
|
|
||||||
this.containerEl.addClass("callout");
|
|
||||||
|
|
||||||
this.titleContainer = this.containerEl.createDiv({ cls: "callout-title" });
|
|
||||||
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" });
|
|
||||||
return this.contentContainerEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderYamlError(r: DeclarationRenderAssistant, errorMessage?: string) {
|
private renderYamlError(r: DeclarationRenderAssistant, errorMessage?: string) {
|
||||||
r.setError();
|
r.setError();
|
||||||
r.setTitle("Invalid format entered");
|
r.setTitle("Invalid format entered");
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,63 @@
|
||||||
import { DeckIDDataTuple } from "data/DataStore";
|
import { DeckData, DeckIDDataTuple } from "#/data/DataStore";
|
||||||
import { DeckableDeclarable, Declarable } from "declarations/Declaration";
|
import { DeckID } from "#/data/FullID";
|
||||||
|
import { DeckableDeclarable } from "#/declarations/Collectionable";
|
||||||
|
import { Declarable } from "#/declarations/Declarable";
|
||||||
|
import { HtmlTag } from "#/utils/dom/constants";
|
||||||
|
import { TableCreator } from "#/utils/dom/table";
|
||||||
import { Component, setIcon } from "obsidian";
|
import { Component, setIcon } from "obsidian";
|
||||||
import { UIAssistant } from "ui/UIAssistant";
|
|
||||||
|
|
||||||
export type DeclarationChangedType = "deckAdded" | "deckChanged";
|
export type DeclarationChangedType = "deckAdded" | "deckChanged";
|
||||||
export type DeclarationChangedEvent = (declaration: DeckableDeclarable, type: DeclarationChangedType, selectEl: HTMLSelectElement) => void;
|
export type DeclarationChangedEvent = (declaration: DeckableDeclarable, type: DeclarationChangedType, selectEl: HTMLSelectElement) => void;
|
||||||
|
|
||||||
export interface DataProvider {
|
export interface DataProvider {
|
||||||
getAllDecks: () => DeckIDDataTuple[];
|
getAllDecks: () => DeckIDDataTuple[];
|
||||||
|
getDeck: (id: DeckID) => DeckData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DeclarationRenderable {
|
export interface DeclarationRenderable {
|
||||||
render(r: DeclarationRenderAssistant): void;
|
render(r: DeclarationRenderAssistant): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FactoryRegistryEntry {
|
||||||
|
names: readonly string[];
|
||||||
|
create: (d: unknown) => DeclarationRenderable | null;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class DeclarationRenderer<T extends Declarable> {
|
export abstract class DeclarationRenderer<T extends Declarable> {
|
||||||
protected declarable: T;
|
protected declarable: T;
|
||||||
public constructor(declarable: T) {
|
|
||||||
this.declarable = declarable;
|
public constructor(declarable: T) { this.declarable = declarable; }
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* @abstract
|
||||||
|
* @returns Whether the renderer can render the given value.
|
||||||
|
*/
|
||||||
|
public static canRender = (_value: unknown): boolean => { throw new Error("Not implemented"); }
|
||||||
|
|
||||||
|
public static readonly Factory = {
|
||||||
|
createEntry: <T extends Declarable>(
|
||||||
|
names: readonly string[],
|
||||||
|
RendererClass: {
|
||||||
|
new(declarable: T): DeclarationRenderable;
|
||||||
|
canRender(value: unknown): value is T;
|
||||||
|
}): FactoryRegistryEntry => {
|
||||||
|
return {
|
||||||
|
names,
|
||||||
|
create: (declarable) => RendererClass.canRender(declarable) ? new RendererClass(declarable) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeclarationRenderAssistant {
|
export class DeclarationRenderAssistant {
|
||||||
|
|
||||||
private containerEl: HTMLElement;
|
private readonly containerEl: HTMLElement;
|
||||||
protected contentContainerEl: HTMLDivElement;
|
private readonly contentContainerEl: HTMLDivElement;
|
||||||
private titleContainer: HTMLDivElement;
|
private readonly titleContainer: HTMLDivElement;
|
||||||
private titleEl: HTMLDivElement;
|
private readonly titleEl: HTMLDivElement;
|
||||||
private component: Component;
|
private readonly component: Component;
|
||||||
private dataProvider: DataProvider;
|
private readonly dataProvider: DataProvider;
|
||||||
protected onDomEvent: DeclarationChangedEvent
|
private readonly onDomEvent: DeclarationChangedEvent;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
containerEl: HTMLElement,
|
containerEl: HTMLElement,
|
||||||
|
|
@ -81,15 +109,19 @@ export class DeclarationRenderAssistant {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public createTable() {
|
||||||
|
return TableCreator.create(this.contentContainerEl);
|
||||||
|
}
|
||||||
|
|
||||||
public createDeckRow(body: HTMLTableSectionElement, declaration: DeckableDeclarable) {
|
public createDeckRow(body: HTMLTableSectionElement, declaration: DeckableDeclarable) {
|
||||||
const rowDeck = body.createEl("tr");
|
const rowDeck = body.createEl("tr");
|
||||||
rowDeck.createEl("td", { text: "Deck" });
|
rowDeck.createEl("td", { text: "Deck" });
|
||||||
const tdDropdown = rowDeck.createEl("td", { cls: "select-deck-cell" });
|
const tdDropdown = rowDeck.createEl("td", { cls: "select-deck-cell" });
|
||||||
|
|
||||||
const deckSelectEl = tdDropdown.createEl("select", { cls: "dropdown" }, (el) => {
|
const deckSelectEl = tdDropdown.createEl(HtmlTag.SELECT.NAME, { cls: "dropdown" }, (el) => {
|
||||||
el.createEl("option", {
|
el.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||||
text: "None",
|
text: "None",
|
||||||
value: UIAssistant.DECK_ID_NONE,
|
value: HtmlTag.SELECT.OPTION.Values.NONE,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -103,11 +135,11 @@ export class DeclarationRenderAssistant {
|
||||||
const decks = this.dataProvider.getAllDecks();
|
const decks = this.dataProvider.getAllDecks();
|
||||||
if (decks.length > 0) {
|
if (decks.length > 0) {
|
||||||
decks.forEach(deck => {
|
decks.forEach(deck => {
|
||||||
deckSelectEl.createEl("option", {
|
deckSelectEl.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||||
text: deck.data.n,
|
text: deck.data.n,
|
||||||
value: deck.id,
|
value: deck.id,
|
||||||
}, (el) => {
|
}, (el) => {
|
||||||
el.selected = declaration?.deckID === deck.id;
|
el.selected = declaration.deckID === deck.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.component.registerDomEvent(deckSelectEl, "change", () => this.onDomEvent(declaration, "deckChanged", deckSelectEl));
|
this.component.registerDomEvent(deckSelectEl, "change", () => this.onDomEvent(declaration, "deckChanged", deckSelectEl));
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
|
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "#/declarations/commands/HeadingAndDelimiter";
|
||||||
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "declarations/commands/HeadingAndDelimiter";
|
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
import { UNARY_UNION_SUPPRESS } from "#/utils/ts";
|
||||||
|
|
||||||
|
const Assistant = HeadingAndDelimiterAssistant;
|
||||||
|
|
||||||
export class HeadingAndDelimiterRenderer
|
export class HeadingAndDelimiterRenderer
|
||||||
extends DeclarationRenderer<HeadingAndDelimiterDeclarable>
|
extends DeclarationRenderer<HeadingAndDelimiterDeclarable>
|
||||||
implements DeclarationRenderable {
|
implements DeclarationRenderable {
|
||||||
|
|
||||||
|
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||||
|
|
||||||
public render(r: DeclarationRenderAssistant) {
|
public render(r: DeclarationRenderAssistant) {
|
||||||
|
|
||||||
if (!HeadingAndDelimiterAssistant.conforms(this.declarable) || !HeadingAndDelimiterAssistant.isValid(this.declarable)) {
|
if (!HeadingAndDelimiterAssistant.is(this.declarable) || !HeadingAndDelimiterAssistant.isValid(this.declarable)) {
|
||||||
r.setError();
|
r.setError();
|
||||||
r.setTitle("Invalid alternate headings command");
|
r.setTitle("Invalid alternate headings command");
|
||||||
r.addParagraph("Please check the entered values.");
|
r.addParagraph("Please check the entered values.");
|
||||||
|
|
@ -22,10 +27,16 @@ export class HeadingAndDelimiterRenderer
|
||||||
text: `Automatically generate a card for every heading ${this.declarable.level} levels below this one.`
|
text: `Automatically generate a card for every heading ${this.declarable.level} levels below this one.`
|
||||||
}),
|
}),
|
||||||
el.createEl("li", undefined, (el) => {
|
el.createEl("li", undefined, (el) => {
|
||||||
if (this.declarable.delimiter === "horizontal rule") {
|
switch (this.declarable.delimiter) {
|
||||||
el.appendText("The back side will begin after the first ");
|
case UNARY_UNION_SUPPRESS:
|
||||||
el.createEl("a", { text: "horizontal rule", href: "https://daringfireball.net/projects/markdown/syntax#hr" });
|
break;
|
||||||
el.appendText(" within the heading’s section.");
|
|
||||||
|
case "horizontal rule": {
|
||||||
|
el.appendText("The back side will begin after the first ");
|
||||||
|
el.createEl("a", { text: "horizontal rule", href: "https://daringfireball.net/projects/markdown/syntax#hr" });
|
||||||
|
el.appendText(" within the heading’s section.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
@ -40,7 +51,7 @@ export class HeadingAndDelimiterRenderer
|
||||||
});
|
});
|
||||||
|
|
||||||
body.createEl("tr", undefined, (el) => {
|
body.createEl("tr", undefined, (el) => {
|
||||||
el.createEl("td", { text: "Side delimiter" });
|
el.createEl("td", { text: "Page delimiter" });
|
||||||
el.createEl("td", { text: "The first horizontal rule" });
|
el.createEl("td", { text: "The first horizontal rule" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "declarations/commands/HeadingIsFront";
|
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "#/declarations/commands/HeadingIsFront";
|
||||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/declarations/DeclarationRenderable";
|
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
|
||||||
|
const Assistant = HeadingIsFrontAssistant;
|
||||||
|
|
||||||
export class HeadingIsFrontRenderer
|
export class HeadingIsFrontRenderer
|
||||||
extends DeclarationRenderer<HeadingIsFrontDeclarable>
|
extends DeclarationRenderer<HeadingIsFrontDeclarable>
|
||||||
implements DeclarationRenderable {
|
implements DeclarationRenderable {
|
||||||
|
|
||||||
|
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||||
|
|
||||||
public render(r: DeclarationRenderAssistant) {
|
public render(r: DeclarationRenderAssistant) {
|
||||||
|
|
||||||
if (!HeadingIsFrontAssistant.conforms(this.declarable) || !HeadingIsFrontAssistant.isValid(this.declarable)) {
|
if (!HeadingIsFrontAssistant.is(this.declarable) || !HeadingIsFrontAssistant.isValid(this.declarable)) {
|
||||||
r.setError();
|
r.setError();
|
||||||
r.setTitle("Invalid heading is front command");
|
r.setTitle("Invalid heading is front command");
|
||||||
r.addParagraph("Please check the entered values.");
|
r.addParagraph("Please check the entered values.");
|
||||||
|
|
|
||||||
83
src/renderings/declarations/PageDeclarationRenderer.ts
Normal file
83
src/renderings/declarations/PageDeclarationRenderer.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { DefaultableCardDeclarable, ExplicitDeclarationAssistant, MaybePageDeclarable } from "#/declarations/ExplicitDeclaration";
|
||||||
|
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||||
|
import { Str } from "utils/ts";
|
||||||
|
|
||||||
|
const Assistant = ExplicitDeclarationAssistant;
|
||||||
|
|
||||||
|
export class PageDeclarationRenderer
|
||||||
|
extends DeclarationRenderer<MaybePageDeclarable>
|
||||||
|
implements DeclarationRenderable {
|
||||||
|
|
||||||
|
public render(r: DeclarationRenderAssistant) {
|
||||||
|
|
||||||
|
if (!Assistant.MaybePage.isValid(this.declarable)) {
|
||||||
|
PageDeclarationRenderer.renderError(r, "`side` must be a string, number, or boolean.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (Assistant.Defaultable.is(this.declarable)) {
|
||||||
|
PageDeclarationRenderer.renderDefaultDeclarable(r, this.declarable);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
PageDeclarationRenderer.renderError(r, "`id` and `side` must be text.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static renderError(r: DeclarationRenderAssistant, msg?: string) {
|
||||||
|
r.setError();
|
||||||
|
r.setTitle("Invalid page declaration");
|
||||||
|
if (msg !== undefined)
|
||||||
|
r.addParagraph(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static renderDefaultDeclarable(r: DeclarationRenderAssistant, declarable: DefaultableCardDeclarable) {
|
||||||
|
|
||||||
|
if (!Assistant.Defaultable.isValid(declarable)) {
|
||||||
|
PageDeclarationRenderer.renderError(r);
|
||||||
|
r.addBulletList(["`side` must be f, front, b or back", "`id` must be a string"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeDeclarable = Assistant.is(declarable) ? declarable : null;
|
||||||
|
const validCompleteDeclarable = completeDeclarable !== null && Assistant.isValid(completeDeclarable) ? completeDeclarable : null;
|
||||||
|
|
||||||
|
if (Assistant.canComplete(declarable) || validCompleteDeclarable !== null) {
|
||||||
|
const isFront = Assistant.isFrontSide(declarable);
|
||||||
|
|
||||||
|
r.setTitle("Page declaration");
|
||||||
|
|
||||||
|
let idValue = validCompleteDeclarable !== null ? validCompleteDeclarable.id : declarable.id;
|
||||||
|
|
||||||
|
if (Assistant.PropertyEq.optionalNullOrString(idValue, (str) => !Str.isNonEmpty(str))) {
|
||||||
|
idValue = isFront ? "Generating…" : Str.EMPTY;
|
||||||
|
if (!isFront)
|
||||||
|
r.addParagraph("`id` is missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
r.createTable().addBody(row => {
|
||||||
|
|
||||||
|
row.add((col) => {
|
||||||
|
col.add({ text: "ID" });
|
||||||
|
col.add({ text: idValue });
|
||||||
|
});
|
||||||
|
|
||||||
|
row.add((col) => {
|
||||||
|
col.add({ text: "Side" });
|
||||||
|
col.add({ text: `${isFront ? "Front" : "Back"}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFront) {
|
||||||
|
r.createDeckRow(row.sectionEl, declarable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else { // validCompleteDeclarable === null
|
||||||
|
PageDeclarationRenderer.renderError(r);
|
||||||
|
|
||||||
|
if (Assistant.isFrontSide(declarable))
|
||||||
|
r.addParagraph("`id` should be a 10 character string.");
|
||||||
|
else
|
||||||
|
r.addParagraph("Add `id` that matches the front side.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Env } from "env";
|
|
||||||
import { FsrsSchedulerConfig } from "scheduling/types";
|
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";
|
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";
|
||||||
|
|
||||||
|
|
@ -15,7 +14,6 @@ export class Fsrs {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static createFromConfig(config: FsrsSchedulerConfig) {
|
private static createFromConfig(config: FsrsSchedulerConfig) {
|
||||||
Env.assert(config);
|
|
||||||
|
|
||||||
const params = generatorParameters({
|
const params = generatorParameters({
|
||||||
request_retention: default_request_retention,
|
request_retention: default_request_retention,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export class SettingTab extends PluginSettingTab {
|
||||||
.addText((component) => {
|
.addText((component) => {
|
||||||
component.setValue(settings.uiPrefix);
|
component.setValue(settings.uiPrefix);
|
||||||
component.onChange(async (value) => {
|
component.onChange(async (value) => {
|
||||||
settings.uiPrefix = value;
|
settings.uiPrefix = value.trim();
|
||||||
await this.settingsManager.save();
|
await this.settingsManager.save();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -46,7 +46,7 @@ export class SettingTab extends PluginSettingTab {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public hide(): void {
|
public override hide(): void {
|
||||||
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
|
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
import { DeckIDDataTuple } from "data/DataStore";
|
|
||||||
import { DeckID } from "data/FullID";
|
|
||||||
import { App, MarkdownView, Menu, MenuItem, Notice } from "obsidian";
|
import { App, MarkdownView, Menu, MenuItem, Notice } from "obsidian";
|
||||||
import { SettingsManager } from "Settings";
|
import { SettingsManager } from "Settings";
|
||||||
import { Icon, PLUGIN_NAME } from "ui/constants";
|
import { Icon } from "ui/constants";
|
||||||
|
import { Str } from "utils/ts";
|
||||||
|
|
||||||
|
|
||||||
export class UIAssistant {
|
export class UIAssistant {
|
||||||
|
|
||||||
/**
|
|
||||||
* Deck ID to use to represent unassigned deck when a string is required to identify UI elements.
|
|
||||||
* For example string values in DOM elements.
|
|
||||||
*/
|
|
||||||
public static readonly DECK_ID_NONE: DeckID = "0000";
|
|
||||||
|
|
||||||
private settingsManager: SettingsManager;
|
private settingsManager: SettingsManager;
|
||||||
|
|
||||||
constructor(settingsManager: SettingsManager) {
|
constructor(settingsManager: SettingsManager) {
|
||||||
|
|
@ -21,10 +14,7 @@ export class UIAssistant {
|
||||||
|
|
||||||
public contextulize(title: string) {
|
public contextulize(title: string) {
|
||||||
const contextPrefix = this.settingsManager.settings.uiPrefix;
|
const contextPrefix = this.settingsManager.settings.uiPrefix;
|
||||||
if (contextPrefix !== undefined)
|
return Str.isNonEmpty(contextPrefix) ? `${contextPrefix}: ${title}` : title;
|
||||||
return contextPrefix ? `${contextPrefix}: ${title}` : title;
|
|
||||||
else
|
|
||||||
return `${PLUGIN_NAME}: ${title}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public addMenuItem(menu: Menu, title: string, options?: {
|
public addMenuItem(menu: Menu, title: string, options?: {
|
||||||
|
|
@ -33,8 +23,8 @@ export class UIAssistant {
|
||||||
icon?: string,
|
icon?: string,
|
||||||
prefix?: boolean,
|
prefix?: boolean,
|
||||||
isLabel?: boolean,
|
isLabel?: boolean,
|
||||||
onClick?: (evt: MouseEvent | KeyboardEvent) => any
|
onClick?: (evt: MouseEvent | KeyboardEvent) => void,
|
||||||
callback?: (item: MenuItem) => any,
|
callback?: (item: MenuItem) => void,
|
||||||
}): Menu {
|
}): Menu {
|
||||||
const {
|
const {
|
||||||
callback,
|
callback,
|
||||||
|
|
@ -42,7 +32,7 @@ export class UIAssistant {
|
||||||
|
|
||||||
menu.addItem(item => {
|
menu.addItem(item => {
|
||||||
this.configureMenuItem(item, title, options);
|
this.configureMenuItem(item, title, options);
|
||||||
callback?.(item);
|
callback?.(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
|
|
@ -54,7 +44,7 @@ export class UIAssistant {
|
||||||
icon?: string,
|
icon?: string,
|
||||||
prefix?: boolean,
|
prefix?: boolean,
|
||||||
isLabel?: boolean,
|
isLabel?: boolean,
|
||||||
onClick?: (evt: MouseEvent | KeyboardEvent) => any
|
onClick?: (evt: MouseEvent | KeyboardEvent) => void,
|
||||||
}): MenuItem {
|
}): MenuItem {
|
||||||
const {
|
const {
|
||||||
section,
|
section,
|
||||||
|
|
@ -103,21 +93,11 @@ export class UIAssistant {
|
||||||
return notice;
|
return notice;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static allDecksOptionItem(): DeckIDDataTuple {
|
|
||||||
return {
|
|
||||||
id: UIAssistant.DECK_ID_NONE,
|
|
||||||
data: {
|
|
||||||
n: "All Decks",
|
|
||||||
p: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isInInLivePreview(app: App) {
|
public static isInInLivePreview(app: App) {
|
||||||
const markdownView = app.workspace.getActiveViewOfType(MarkdownView)
|
const markdownView = app.workspace.getActiveViewOfType(MarkdownView)
|
||||||
if (!markdownView)
|
if (!markdownView)
|
||||||
return false;
|
return false;
|
||||||
const state = markdownView.getState();
|
const state = markdownView.getState();
|
||||||
return state ? state.mode == "source" && state.source == false : false;
|
return state["mode"] == "source" && state["source"] == false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Env } from "env";
|
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
|
// Make sure you're doing import { moment} from 'obsidian' so that you don't import another copy — https://docs.obsidian.md/oo24/plugin#Performance
|
||||||
|
// Regarding use of `@ts-expect-error`: Obsidian's recommended import style (import { moment } from 'obsidian') is typed as a namespace and is flagged as "not callable" by TypeScript 6.0's stricter checks. Instead of setting `esModuleInterop` to `true` in `tsconfig` just use `@ts-expect-error` here.
|
||||||
import { moment } from "obsidian"; // TODO: Use Luxon
|
import { moment } from "obsidian"; // TODO: Use Luxon
|
||||||
import { Str } from "utils/ts";
|
import { Str } from "utils/ts";
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
|
@ -9,16 +10,20 @@ const TIME_FORMAT = "LT";
|
||||||
const DATE_FORMAT = "MMM D, LT";
|
const DATE_FORMAT = "MMM D, LT";
|
||||||
|
|
||||||
export const DateTime = {
|
export const DateTime = {
|
||||||
|
// @ts-expect-error
|
||||||
toTimeString: (date: Date) => moment(date).format(TIME_FORMAT),
|
toTimeString: (date: Date) => moment(date).format(TIME_FORMAT),
|
||||||
|
// @ts-expect-error
|
||||||
toString: (date: Date) => moment(date).format(DATE_FORMAT),
|
toString: (date: Date) => moment(date).format(DATE_FORMAT),
|
||||||
|
|
||||||
dateStringFromIso: (iso8601: string): string => {
|
dateStringFromIso: (iso8601: string): string => {
|
||||||
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
||||||
|
// @ts-expect-error
|
||||||
return moment(iso8601).format(DATE_FORMAT);
|
return moment(iso8601).format(DATE_FORMAT);
|
||||||
},
|
},
|
||||||
|
|
||||||
dateFromIso: (iso8601: string): Date => {
|
dateFromIso: (iso8601: string): Date => {
|
||||||
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
||||||
|
// @ts-expect-error
|
||||||
return moment(iso8601).toDate();
|
return moment(iso8601).toDate();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
|
import { Str } from "#/utils/ts";
|
||||||
|
|
||||||
export const HtmlTag = {
|
export const HtmlTag = {
|
||||||
SPAN: "span",
|
SPAN: "span",
|
||||||
|
SELECT: {
|
||||||
|
NAME: "select",
|
||||||
|
OPTION: {
|
||||||
|
NAME: "option",
|
||||||
|
Values: {
|
||||||
|
/** Represents an empty option, used as a placeholder or for no selection. */
|
||||||
|
NONE: Str.EMPTY,
|
||||||
|
},
|
||||||
|
isNone: (value: string): boolean => value === Str.EMPTY,
|
||||||
|
select: (el: HTMLOptionElement) => el.selected = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const HtmlAttribute = {
|
export const HtmlAttribute = {
|
||||||
|
|
@ -23,7 +37,7 @@ export const HtmlAttribute = {
|
||||||
Controls: {
|
Controls: {
|
||||||
NAME: "controls",
|
NAME: "controls",
|
||||||
Values: {
|
Values: {
|
||||||
DISPLAY: ""
|
DISPLAY: Str.EMPTY
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** The controlsList property of an HTMLMediaElement (like <video> or <audio>) allows you to control which built-in playback controls the user agent (browser) should display. */
|
/** The controlsList property of an HTMLMediaElement (like <video> or <audio>) allows you to control which built-in playback controls the user agent (browser) should display. */
|
||||||
|
|
@ -39,7 +53,7 @@ export const HtmlAttribute = {
|
||||||
Loop: {
|
Loop: {
|
||||||
NAME: "loop",
|
NAME: "loop",
|
||||||
Values: {
|
Values: {
|
||||||
DISPLAY: ""
|
DISPLAY: Str.EMPTY
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preload
|
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preload
|
||||||
|
|
|
||||||
44
src/utils/dom/dom.ts
Normal file
44
src/utils/dom/dom.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Provided by Obsidian:
|
||||||
|
// On `Element`: `addClass`, `doc`, `removeClass`, `hasClass`, `toggleClass`, `win`
|
||||||
|
|
||||||
|
|
||||||
|
export const Doc = {
|
||||||
|
/** Returns the document that the given element is located in. */
|
||||||
|
get: (el: HTMLElement) => el.doc,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const El = {
|
||||||
|
setAttribute: (el: HTMLElement, name: string, value: string) => {
|
||||||
|
//If the attribute already exists, the value is updated; otherwise a new attribute is added with the specified name and value.
|
||||||
|
el.setAttribute(name, value);
|
||||||
|
},
|
||||||
|
removeAttribute: (el: HTMLElement, name: string) => {
|
||||||
|
el.removeAttribute(name);
|
||||||
|
},
|
||||||
|
|
||||||
|
Cls: {
|
||||||
|
add: (el: HTMLElement | undefined, className: string) => {
|
||||||
|
if (el !== undefined && !el.hasClass(className))
|
||||||
|
el.addClass(className);
|
||||||
|
},
|
||||||
|
remove: (el: HTMLElement | undefined, className: string) => {
|
||||||
|
if (el !== undefined && el.hasClass(className))
|
||||||
|
el.removeClass(className);
|
||||||
|
},
|
||||||
|
has: (el: HTMLElement | undefined, className: string) => {
|
||||||
|
return el !== undefined && el.hasClass(className);
|
||||||
|
},
|
||||||
|
toggle: (el: HTMLElement | undefined, className: string, value: boolean) => {
|
||||||
|
el?.toggleClass(className, value);
|
||||||
|
},
|
||||||
|
} as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const Win = {
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: (el: HTMLElement) => el.win,
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
export class TableCreator {
|
||||||
|
public static create(parent: HTMLElement): TableSectionCreator {
|
||||||
|
return new TableSectionCreator(parent.createEl("table"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TableSectionCreator {
|
export class TableSectionCreator {
|
||||||
public readonly tableEl: HTMLTableElement;
|
public readonly tableEl: HTMLTableElement;
|
||||||
|
|
||||||
|
|
@ -5,22 +11,22 @@ export class TableSectionCreator {
|
||||||
this.tableEl = tableEl;
|
this.tableEl = tableEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setHeader(cb: (rowCreator: TableRowCreator) => void) {
|
public setHeader(cb?: (rowCreator: TableRowCreator) => void): TableRowCreator {
|
||||||
return this.create("thead", cb);
|
return this.create("thead", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** It is possible to have multiple <tbody> elements in the same table. */
|
/** It is possible to have multiple <tbody> elements in the same table. */
|
||||||
public addBody(cb: (rowCreator: TableRowCreator) => void) {
|
public addBody(cb?: (rowCreator: TableRowCreator) => void): TableRowCreator {
|
||||||
return this.create("tbody", cb);
|
return this.create("tbody", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setFooter(cb: (rowCreator: TableRowCreator) => void) {
|
public setFooter(cb?: (rowCreator: TableRowCreator) => void): TableRowCreator {
|
||||||
return this.create("tfoot", cb);
|
return this.create("tfoot", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
private create(tag: "thead" | "tbody" | "tfoot", cb: (rowCreator: TableRowCreator) => void) {
|
private create(tag: "thead" | "tbody" | "tfoot", cb?: (rowCreator: TableRowCreator) => void) {
|
||||||
const r = new TableRowCreator(this.tableEl.createEl(tag), tag === "thead");
|
const r = new TableRowCreator(this.tableEl.createEl(tag), tag === "thead");
|
||||||
cb(r);
|
cb?.(r);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
src/utils/obs/DomState.ts
Normal file
102
src/utils/obs/DomState.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { El } from "#/utils/dom/dom";
|
||||||
|
import { CssClass } from "#/utils/obs/constants";
|
||||||
|
import { Arr, Str } from "#/utils/ts";
|
||||||
|
import { Plugin } from "obsidian";
|
||||||
|
|
||||||
|
type SettingsChangeCallback = (added: string[], removed: string[]) => void;
|
||||||
|
|
||||||
|
interface Subscriber {
|
||||||
|
callback: SettingsChangeCallback;
|
||||||
|
classes: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DomState {
|
||||||
|
private static observer: MutationObserver | null = null;
|
||||||
|
private static subscribers: Subscriber[] = [];
|
||||||
|
private static doc: Document | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call from `onload` of the plugin.
|
||||||
|
* @param plugin
|
||||||
|
* @param doc Document of the main window.
|
||||||
|
*/
|
||||||
|
public static init(plugin: Plugin, doc: Document) {
|
||||||
|
DomState.doc = doc;
|
||||||
|
plugin.register(() => DomState.unobserve());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static deinit() {
|
||||||
|
Arr.clear(DomState.subscribers);
|
||||||
|
DomState.unobserve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static subscribe(callback: SettingsChangeCallback, classes: string[] | null = null) {
|
||||||
|
DomState.subscribers.push({ callback, classes });
|
||||||
|
if (DomState.subscribers.length === 1)
|
||||||
|
DomState.observe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsubscribe(callback: SettingsChangeCallback) {
|
||||||
|
DomState.subscribers = DomState.subscribers.filter(s => s.callback !== callback);
|
||||||
|
if (DomState.subscribers.length === 0)
|
||||||
|
DomState.unobserve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static observe() {
|
||||||
|
const doc = DomState.docOrThrow;
|
||||||
|
|
||||||
|
DomState.observer = new MutationObserver(DomState.onMutation);
|
||||||
|
DomState.observer.observe(doc.body, { attributeFilter: ["class"], attributeOldValue: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static onMutation = (mutations: MutationRecord[]) => {
|
||||||
|
const doc = DomState.docOrThrow;
|
||||||
|
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const oldClasses = new Set((mutation.oldValue ?? Str.EMPTY).split(Str.SPACE).filter(Boolean));
|
||||||
|
const newClasses = new Set(doc.body.className.split(Str.SPACE).filter(Boolean));
|
||||||
|
|
||||||
|
const added = [...newClasses].filter(c => !oldClasses.has(c));
|
||||||
|
const removed = [...oldClasses].filter(c => !newClasses.has(c));
|
||||||
|
|
||||||
|
if (added.length === 0 && removed.length === 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (const subscriber of DomState.subscribers) {
|
||||||
|
if (subscriber.classes === null) {
|
||||||
|
subscriber.callback(added, removed);
|
||||||
|
} else {
|
||||||
|
const filteredAdded = added.filter(c => subscriber.classes!.includes(c));
|
||||||
|
const filteredRemoved = removed.filter(c => subscriber.classes!.includes(c));
|
||||||
|
if (filteredAdded.length > 0 || filteredRemoved.length > 0) {
|
||||||
|
subscriber.callback(filteredAdded, filteredRemoved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unobserve() {
|
||||||
|
DomState.observer?.disconnect();
|
||||||
|
DomState.observer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static get docOrThrow() {
|
||||||
|
if (DomState.doc === undefined)
|
||||||
|
throw new Error(`${DomState.name} not initiated.`);
|
||||||
|
return DomState.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly UserSetting = {
|
||||||
|
/** See also {@link InternalApi.getConfig} */
|
||||||
|
get isAutoFullScreenEnabled(): boolean {
|
||||||
|
return El.Cls.has(DomState.docOrThrow.body, CssClass.Body.Setting.AUTO_FULL_SCREEN);
|
||||||
|
},
|
||||||
|
} as const ;
|
||||||
|
|
||||||
|
public static readonly Interface = {
|
||||||
|
isNavigationHidden(doc: Document | undefined = DomState.docOrThrow): boolean {
|
||||||
|
return El.Cls.has(doc.body, CssClass.Body.Interface.IS_HIDDEN_NAV);
|
||||||
|
},
|
||||||
|
} as const ;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { App, CacheItem, HeadingCache, Pos, SectionCache, TFile } from "obsidian";
|
import { App, CacheItem, HeadingCache, Pos, SectionCache, TFile } from "obsidian";
|
||||||
|
import { UNARY_UNION_SUPPRESS } from "#/utils/ts";
|
||||||
|
|
||||||
interface FileParserErrorOptions extends ErrorOptions {
|
interface FileParserErrorOptions extends ErrorOptions {
|
||||||
type: "file cache unavailable";
|
type: "file cache unavailable";
|
||||||
|
|
@ -24,6 +25,11 @@ export class FileParserError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OffsetRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** A part of a file's content delimited by two {@link SectionCache} */
|
/** A part of a file's content delimited by two {@link SectionCache} */
|
||||||
export type SectionRange = {
|
export type SectionRange = {
|
||||||
/** If `null`, starts at the beginning.*/
|
/** If `null`, starts at the beginning.*/
|
||||||
|
|
@ -36,7 +42,7 @@ export type CodeBlockInfo = {
|
||||||
/** May be an empty string if language is not specified. */
|
/** May be an empty string if language is not specified. */
|
||||||
language: string;
|
language: string;
|
||||||
/** The location of the content relative to the first tick of the block. */
|
/** The location of the content relative to the first tick of the block. */
|
||||||
location: { start: number, end: number};
|
location: OffsetRange;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,10 +69,13 @@ const FrontmatterSection: ExternalSectionCache = {
|
||||||
* Sections of the file that are not part of the actual Markdown.
|
* Sections of the file that are not part of the actual Markdown.
|
||||||
*/
|
*/
|
||||||
interface ExternalSectionCache extends SectionCache {
|
interface ExternalSectionCache extends SectionCache {
|
||||||
externalType: "frontmatter" //| "backmatter"
|
externalType: "frontmatter" | typeof UNARY_UNION_SUPPRESS //| "backmatter"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provides a set of common helpers for interpreting the structure and metadata of markdown files. */
|
/**
|
||||||
|
* Provides a set of common helpers for interpreting the structure and metadata of markdown files.
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
export abstract class FileParser {
|
export abstract class FileParser {
|
||||||
|
|
||||||
protected static readonly SECTION_TYPE_HEADING = "heading";
|
protected static readonly SECTION_TYPE_HEADING = "heading";
|
||||||
|
|
@ -125,7 +134,6 @@ export abstract class FileParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @todo This method does not support code blocks that start with four characters.
|
|
||||||
* @param source The code block including the three ticks at the beginning and end. See {@link extractContentFromSection}.
|
* @param source The code block including the three ticks at the beginning and end. See {@link extractContentFromSection}.
|
||||||
* @returns `null` is {@link source} is not a code block.
|
* @returns `null` is {@link source} is not a code block.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
221
src/utils/obs/InteractionAssistant.ts
Normal file
221
src/utils/obs/InteractionAssistant.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { Env } from "#/env";
|
||||||
|
import { El } from "#/utils/dom/dom";
|
||||||
|
import { CssClass } from "#/utils/obs/constants";
|
||||||
|
import { DomState } from "#/utils/obs/DomState";
|
||||||
|
import { InternalApi } from "#/utils/obs/internal";
|
||||||
|
import { ViewAssistant } from "#/utils/obs/ViewAssistant";
|
||||||
|
import { App, Component, debounce } from "obsidian";
|
||||||
|
|
||||||
|
|
||||||
|
export class InteractionAssistant {
|
||||||
|
|
||||||
|
private component: Component;
|
||||||
|
private viewAssistant: ViewAssistant;
|
||||||
|
private doc: Document | undefined;
|
||||||
|
private app: App;
|
||||||
|
|
||||||
|
constructor(app: App, component: Component, viewAssistant: ViewAssistant) {
|
||||||
|
this.app = app;
|
||||||
|
this.component = component;
|
||||||
|
this.viewAssistant = viewAssistant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(doc: Document) {
|
||||||
|
Env.assert(this.viewAssistant.isInitialized, `${ViewAssistant.name} must be initialized before ${InteractionAssistant.name}`);
|
||||||
|
|
||||||
|
this.doc = doc;
|
||||||
|
this.component.registerDomEvent(this.viewAssistant.scrollContainer, "scroll", this.onScroll);
|
||||||
|
this.scroll.resetState();
|
||||||
|
this.ui.isNavigationHidden = DomState.Interface.isNavigationHidden(this.docOrThrow);
|
||||||
|
|
||||||
|
DomState.subscribe(this.onStateChange, [
|
||||||
|
CssClass.Body.Setting.AUTO_FULL_SCREEN,
|
||||||
|
CssClass.Body.Platform.IS_PHONE,
|
||||||
|
CssClass.Body.Interface.IS_HIDDEN_NAV,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isInitialized(): boolean {
|
||||||
|
return this.doc !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public deinit() {
|
||||||
|
DomState.unsubscribe(this.onStateChange);
|
||||||
|
this.ui.showNavigation(true);
|
||||||
|
this.doubleTap.clearMouseDownTimer();
|
||||||
|
this.doc = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerDoubleClick(el: HTMLElement, onDoubleTap: () => void) {
|
||||||
|
this.doubleTap.reg(el, onDoubleTap);
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextScrollIsProgrammatic() {
|
||||||
|
this.scroll.nextScrollIsProgrammatic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get docOrThrow() {
|
||||||
|
if (this.doc === undefined)
|
||||||
|
throw new Error(`${InteractionAssistant.name} not initiated.`);
|
||||||
|
return this.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStateChange = (added: string[], removed: string[]) => {
|
||||||
|
|
||||||
|
if (added.includes(CssClass.Body.Setting.AUTO_FULL_SCREEN)) {
|
||||||
|
this.scroll.previousScrollTop = 0;
|
||||||
|
this.ui.isAutoFullScreenEnabled = true;
|
||||||
|
}
|
||||||
|
else if (removed.includes(CssClass.Body.Setting.AUTO_FULL_SCREEN)) {
|
||||||
|
// Full screen disabled, make sure navigation is shown.
|
||||||
|
this.ui.showNavigation(true);
|
||||||
|
this.ui.isAutoFullScreenEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.includes(CssClass.Body.Interface.IS_HIDDEN_NAV))
|
||||||
|
this.ui.isNavigationHidden = true;
|
||||||
|
else if (removed.includes(CssClass.Body.Interface.IS_HIDDEN_NAV))
|
||||||
|
this.ui.isNavigationHidden = false;
|
||||||
|
|
||||||
|
if (DomState.UserSetting.isAutoFullScreenEnabled) {
|
||||||
|
// Make sure that navigation is shown intitally when screen size changes e.g. from tablet to phone.
|
||||||
|
if (added.includes(CssClass.Body.Platform.IS_PHONE) || removed.includes(CssClass.Body.Platform.IS_PHONE))
|
||||||
|
this.ui.showNavigation(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onScroll = () => {
|
||||||
|
if (!this.isInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.scroll.on();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ui = {
|
||||||
|
isAutoFullScreenEnabled: DomState.UserSetting.isAutoFullScreenEnabled,
|
||||||
|
isNavigationHidden: false, // Needs doc. Set in init.
|
||||||
|
|
||||||
|
showNavigation: (show: boolean) => {
|
||||||
|
if (show === !this.ui.isNavigationHidden)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This will hide the status bar as well.
|
||||||
|
InternalApi.hideNav(this.app, !show);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// This is a fallback in case the above call fails. This hides the navigation controls but not the status bar.
|
||||||
|
const isHidden = DomState.Interface.isNavigationHidden(this.docOrThrow); // The callback has not yet triggered, so we need to look at DOM directly.
|
||||||
|
if (show && isHidden || !show && !isHidden)
|
||||||
|
El.Cls.toggle(this.docOrThrow.body, CssClass.Body.Interface.IS_HIDDEN_NAV, !show);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly scroll = {
|
||||||
|
|
||||||
|
on() {
|
||||||
|
if (this.nextScrollIsProgrammatic) {
|
||||||
|
this.nextScrollIsProgrammatic = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onScroll();
|
||||||
|
},
|
||||||
|
|
||||||
|
onScroll: () => {
|
||||||
|
if (this.ui.isAutoFullScreenEnabled) {
|
||||||
|
|
||||||
|
/* Obsidians implementation:
|
||||||
|
e.prototype.onScroll = function(e, t) {
|
||||||
|
var n, i = null !== (n = this.scrollTops.get(e)) && void 0 !== n ? n : 0;
|
||||||
|
if (this.scrollTops.set(e, t),
|
||||||
|
Yl.isPhone && !Yl.mobileSoftKeyboardVisible && this.app.vault.getConfig("autoFullScreen")) {
|
||||||
|
var r = t - i;
|
||||||
|
t < .1 && i < .1 || Math.abs(r) < .125 || (r > 0 ? this.hideNavigation() : this.restoreNavigation())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const maxScroll = this.viewAssistant.scrollContainer.scrollHeight - this.viewAssistant.scrollContainer.clientHeight;
|
||||||
|
const currentScrollTop = Math.min(Math.max(0, this.viewAssistant.scrollContainer.scrollTop), maxScroll);
|
||||||
|
const delta = currentScrollTop - this.scroll.previousScrollTop;
|
||||||
|
const previousScrollTop = this.scroll.previousScrollTop;
|
||||||
|
this.scroll.previousScrollTop = currentScrollTop;
|
||||||
|
|
||||||
|
// 0.125 is probably just guarding against floating point values that are technically non-zero but represent no real movement.
|
||||||
|
if (!(currentScrollTop < 0.1 && previousScrollTop < 0.1) && Math.abs(delta) >= 0.125)
|
||||||
|
this.ui.showNavigation(delta <= 0);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.ui.showNavigation(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleScrollDebounced: debounce(() => {
|
||||||
|
this.scroll.onScroll();
|
||||||
|
}, 50, true),
|
||||||
|
|
||||||
|
previousScrollTop: 0,
|
||||||
|
|
||||||
|
/** Used when a scroll event will be triggered programmatically to disable scroll handling temporarily. */
|
||||||
|
nextScrollIsProgrammatic: false,
|
||||||
|
|
||||||
|
resetState() {
|
||||||
|
this.previousScrollTop = 0;
|
||||||
|
this.nextScrollIsProgrammatic = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsidian adds code so that navigation is restored when user taps once.
|
||||||
|
* This method catches the `mousedown` event and stops its propagation if navigation is hidden.
|
||||||
|
* It then re-dispatches the event after a short delay if no double tap occurred.
|
||||||
|
*/
|
||||||
|
private readonly doubleTap = {
|
||||||
|
|
||||||
|
reg: (el: HTMLElement, onDoubleTap: () => void) => {
|
||||||
|
/*
|
||||||
|
Obsidian adds this code so that navigation is restored when user taps once.
|
||||||
|
```
|
||||||
|
window.addEventListener("mousedown", (function() {
|
||||||
|
return t.restoreNavigation(!0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Catch it before it propagates to the window.
|
||||||
|
*/
|
||||||
|
this.component.registerDomEvent(el, 'mousedown', (e) => {
|
||||||
|
if (DomState.Interface.isNavigationHidden(this.docOrThrow)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.doubleTap.dispatchMouseDown(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.component.registerDomEvent(el, "dblclick", () => {
|
||||||
|
this.doubleTap.clearMouseDownTimer();
|
||||||
|
onDoubleTap();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dispatchMouseDown: (e: MouseEvent) => {
|
||||||
|
this.doubleTap.clearMouseDownTimer();
|
||||||
|
|
||||||
|
const win = this.docOrThrow.win;
|
||||||
|
this.doubleTap.mouseDownTimer = win.setTimeout(() => {
|
||||||
|
this.doubleTap.mouseDownTimer = null;
|
||||||
|
win.dispatchEvent(new MouseEvent('mousedown', e));
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
clearMouseDownTimer: () => {
|
||||||
|
if (this.doubleTap.mouseDownTimer !== null) {
|
||||||
|
this.docOrThrow.win.clearTimeout(this.doubleTap.mouseDownTimer);
|
||||||
|
this.doubleTap.mouseDownTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mouseDownTimer: null as number | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -21,19 +21,19 @@ export abstract class RecycleComponent extends Component {
|
||||||
return super.addChild(component);
|
return super.addChild(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override removeChild<T extends Component>(component: T): T {
|
public override removeChild<T extends Component>(component: T): T {
|
||||||
Env.assert(false);
|
Env.assert(false);
|
||||||
return super.removeChild(component);
|
return super.removeChild(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@link recycleComponent} was loaded.
|
* The {@link recycleComponent} was loaded.
|
||||||
* Create and add children here.
|
* Create and add children here.
|
||||||
*/
|
*/
|
||||||
protected onRecycled(component: Component): void {};
|
protected onRecycled(component: Component): void { }; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
/** The {@link recycleComponent} is about to unload. */
|
/** The {@link recycleComponent} is about to unload. */
|
||||||
protected onRecycling(): void {};
|
protected onRecycling(): void { };
|
||||||
|
|
||||||
protected get recycleComponent() {
|
protected get recycleComponent() {
|
||||||
return this.internalRecycleComponent!;
|
return this.internalRecycleComponent!;
|
||||||
|
|
@ -46,15 +46,12 @@ export abstract class RecycleComponent extends Component {
|
||||||
|
|
||||||
private create(): void {
|
private create(): void {
|
||||||
this.internalRecycleComponent = new Component();
|
this.internalRecycleComponent = new Component();
|
||||||
|
this.internalRecycleComponent.load();
|
||||||
if (this.internalRecycleComponent) {
|
this.onRecycled(this.internalRecycleComponent);
|
||||||
this.internalRecycleComponent.load();
|
|
||||||
this.onRecycled(this.internalRecycleComponent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispose(): void {
|
private dispose(): void {
|
||||||
if (this.internalRecycleComponent) {
|
if (this.internalRecycleComponent !== null) {
|
||||||
this.onRecycling();
|
this.onRecycling();
|
||||||
// Clears its internal list of children and deregisters all its resources. The component is disposed and should no longer be used.
|
// Clears its internal list of children and deregisters all its resources. The component is disposed and should no longer be used.
|
||||||
this.internalRecycleComponent.unload();
|
this.internalRecycleComponent.unload();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { Env } from "env";
|
import { Env } from "#/env";
|
||||||
|
import { El } from "#/utils/dom/dom";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { CssClass, HtmlAttribute } from "#/utils/obs/constants";
|
||||||
|
import { Arr } from "#/utils/ts";
|
||||||
import { ItemView } from "obsidian";
|
import { ItemView } from "obsidian";
|
||||||
import { UnexpectedUndefinedError } from "utils/errors";
|
|
||||||
import { CssClass } from "utils/obs/constants";
|
|
||||||
import { Arr } from "utils/ts";
|
|
||||||
|
|
||||||
|
/** Knows about the Obsidian specifc DOM structure. */
|
||||||
export class ViewAssistant {
|
export class ViewAssistant {
|
||||||
|
|
||||||
|
/** `<div class="workspace-leaf-content …>` */
|
||||||
|
private workspaceLeafContentEl: HTMLElement | undefined;
|
||||||
private markdownViewRootEl: HTMLDivElement | undefined;
|
private markdownViewRootEl: HTMLDivElement | undefined;
|
||||||
private previewView: HTMLDivElement | undefined;
|
private previewView: HTMLDivElement | undefined;
|
||||||
private previewSizerEl: HTMLDivElement | undefined;
|
private previewSizerEl: HTMLDivElement | undefined;
|
||||||
|
|
@ -13,6 +17,9 @@ export class ViewAssistant {
|
||||||
public init(view: ItemView) {
|
public init(view: ItemView) {
|
||||||
this.deinit();
|
this.deinit();
|
||||||
|
|
||||||
|
this.workspaceLeafContentEl = view.containerEl;
|
||||||
|
El.setAttribute(this.workspaceLeafContentEl, HtmlAttribute.Data.MODE, CssClass.Workspace.Data.PREVIEW_MODE);
|
||||||
|
|
||||||
this.markdownViewRootEl = view.contentEl.createDiv({ cls: CssClass.MarkdownView.READING }, (readerViewEl) => {
|
this.markdownViewRootEl = view.contentEl.createDiv({ cls: CssClass.MarkdownView.READING }, (readerViewEl) => {
|
||||||
this.previewView = readerViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.PREVIEW) }, (previewViewEl) => {
|
this.previewView = readerViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.PREVIEW) }, (previewViewEl) => {
|
||||||
this.previewSizerEl = previewViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.SIZER) }, (el) => {
|
this.previewSizerEl = previewViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.SIZER) }, (el) => {
|
||||||
|
|
@ -23,8 +30,18 @@ export class ViewAssistant {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get isInitialized(): boolean {
|
||||||
|
return this.markdownViewRootEl !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Undos what {@link init} did. */
|
/** Undos what {@link init} did. */
|
||||||
public deinit() {
|
public deinit() {
|
||||||
|
|
||||||
|
if (this.workspaceLeafContentEl !== undefined) {
|
||||||
|
El.removeAttribute(this.workspaceLeafContentEl, HtmlAttribute.Data.MODE);
|
||||||
|
this.workspaceLeafContentEl = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.markdownViewRootEl) {
|
if (this.markdownViewRootEl) {
|
||||||
this.markdownViewRootEl.empty();
|
this.markdownViewRootEl.empty();
|
||||||
this.markdownViewRootEl = undefined;
|
this.markdownViewRootEl = undefined;
|
||||||
|
|
@ -33,7 +50,7 @@ export class ViewAssistant {
|
||||||
}
|
}
|
||||||
|
|
||||||
public empty() {
|
public empty() {
|
||||||
if (this.previewSizerEl) {
|
if (this.previewSizerEl !== undefined) {
|
||||||
const children = this.previewSizerEl.children;
|
const children = this.previewSizerEl.children;
|
||||||
for (let i = children.length - 1; i >= 2; i--) {
|
for (let i = children.length - 1; i >= 2; i--) {
|
||||||
const child = children[i];
|
const child = children[i];
|
||||||
|
|
@ -44,33 +61,37 @@ export class ViewAssistant {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @throws `Error` if {@link init} has not been called. */
|
||||||
|
public get workspaceLeafEl() {
|
||||||
|
this.throwIfNotInitialized(this.workspaceLeafContentEl);
|
||||||
|
return this.workspaceLeafContentEl;
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws `Error` if {@link init} has not been called. */
|
/** @throws `Error` if {@link init} has not been called. */
|
||||||
public get containerEl() {
|
public get containerEl() {
|
||||||
if (this.markdownViewRootEl === undefined)
|
this.throwIfNotInitialized(this.markdownViewRootEl);
|
||||||
throw new Error("Not initialized.");
|
|
||||||
return this.markdownViewRootEl;
|
return this.markdownViewRootEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws `Error` if {@link init} has not been called. */
|
/** @throws `Error` if {@link init} has not been called. */
|
||||||
public get contentEl() {
|
public get contentEl() {
|
||||||
if (this.previewSizerEl === undefined)
|
this.throwIfNotInitialized(this.previewSizerEl);
|
||||||
throw new Error("Not initialized.");
|
|
||||||
return this.previewSizerEl;
|
return this.previewSizerEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @throws `Error` if {@link init} has not been called. */
|
||||||
public get scrollContainer() {
|
public get scrollContainer() {
|
||||||
|
this.throwIfNotInitialized(this.previewView);
|
||||||
return this.previewView;
|
return this.previewView;
|
||||||
}
|
}
|
||||||
|
|
||||||
public adjustAvailableVerticalScrolling() {
|
public adjustAvailableVerticalScrolling() {
|
||||||
if (!this.scrollContainer || !this.previewSizerEl)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const containerHeight = this.scrollContainer.clientHeight;
|
const containerHeight = this.scrollContainer.clientHeight;
|
||||||
const paddingBottom = Math.floor(containerHeight * 0.5);
|
const paddingBottom = Math.floor(containerHeight * 0.5);
|
||||||
const minHeight = Math.floor(containerHeight * 0.53);
|
const minHeight = Math.floor(containerHeight * 0.53);
|
||||||
|
|
||||||
this.previewSizerEl.setCssStyles({
|
this.contentEl.setCssStyles({
|
||||||
"paddingBottom": `${paddingBottom}px`,
|
"paddingBottom": `${paddingBottom}px`,
|
||||||
"minHeight": `${minHeight}px`,
|
"minHeight": `${minHeight}px`,
|
||||||
});
|
});
|
||||||
|
|
@ -78,4 +99,8 @@ export class ViewAssistant {
|
||||||
Env.log.view(`ViewAssistant:adjustAvailableVerticalScrolling: \`padding-bottom\`: "${paddingBottom}px", \`min-height\`: "${minHeight}px"`);
|
Env.log.view(`ViewAssistant:adjustAvailableVerticalScrolling: \`padding-bottom\`: "${paddingBottom}px", \`min-height\`: "${minHeight}px"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private throwIfNotInitialized<T extends HTMLElement | undefined>(el: T): asserts el is Exclude<T, undefined> {
|
||||||
|
if (el === undefined)
|
||||||
|
throw new Error("Not initialized.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,50 @@
|
||||||
|
export const HtmlAttribute = {
|
||||||
|
Data: {
|
||||||
|
MODE: "data-mode"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const CssClass = {
|
export const CssClass = {
|
||||||
|
|
||||||
wrapperClassForEl: <K extends keyof HTMLElementTagNameMap>(tag: K) => {
|
wrapperClassForEl: <K extends keyof HTMLElementTagNameMap>(tag: K) => {
|
||||||
return "el-" + tag;
|
return "el-" + tag;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
|
||||||
|
Setting: {
|
||||||
|
/**
|
||||||
|
* Added when "Full screen" is enabled in settings.
|
||||||
|
* "Automatically hide interface elements while reading."
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
AUTO_FULL_SCREEN: "auto-full-screen",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added when "Floating navigation" is enabled in settings.
|
||||||
|
* "Navigation buttons float over the content instead of being anchored."
|
||||||
|
*/
|
||||||
|
IS_FLOATING_NAV: "is-floating-nav",
|
||||||
|
},
|
||||||
|
|
||||||
|
Platform: {
|
||||||
|
IS_PHONE: "is-phone",
|
||||||
|
},
|
||||||
|
|
||||||
|
Interface: {
|
||||||
|
/** Added when the navigation such as the header view is hidden (or about to be hidden once transitioned). */
|
||||||
|
IS_HIDDEN_NAV: "is-hidden-nav",
|
||||||
|
IS_SHOW_INDENT_GUIDE: "show-indentation-guide"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Workspace: {
|
||||||
|
LEAF_CONTENT_MODIFIER: "workspace-leaf-content",
|
||||||
|
Data: {
|
||||||
|
PREVIEW_MODE: "preview"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
MarkdownView: {
|
MarkdownView: {
|
||||||
READING: "markdown-reading-view",
|
READING: "markdown-reading-view",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,17 +84,3 @@ export function createElWrapperHtml<K extends keyof HTMLElementTagNameMap>(tag:
|
||||||
else
|
else
|
||||||
return `<div class="${CssClass.wrapperClassForEl(tag)}">${html}</div>`;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Env } from "env";
|
import { Env } from "#/env";
|
||||||
import { App, KeymapEventListener, Scope } from "obsidian";
|
import { Bln } from "#/utils/ts";
|
||||||
|
import { App, KeymapEventListener, Scope, Vault } from "obsidian";
|
||||||
|
|
||||||
export const InternalApi = {
|
export const InternalApi = {
|
||||||
|
|
||||||
|
|
@ -41,4 +42,28 @@ export const InternalApi = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getConfig: (vault: Vault, key: "autoFullScreen" | "floatingNavigation" | "showInlineTitle" | "showIndentGuide" | "rightToLeft") => {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error
|
||||||
|
const value = vault.getConfig(key);
|
||||||
|
return Bln.isTrue(value);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
Env.log.e("Failed to get config.", e, key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hideNav: (app: App, hide: boolean) => {
|
||||||
|
try {
|
||||||
|
if (hide)
|
||||||
|
(app as any).mobileNavbar?.hideNavigation(); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
else
|
||||||
|
(app as any).mobileNavbar?.restoreNavigation(); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
Env.log.e("Failed to hide navigation.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
152
src/utils/ts.ts
152
src/utils/ts.ts
|
|
@ -2,11 +2,20 @@ import { Env } from "env";
|
||||||
import { UnsignedInteger } from "types";
|
import { UnsignedInteger } from "types";
|
||||||
import { TimeoutError } from "utils/errors";
|
import { TimeoutError } from "utils/errors";
|
||||||
|
|
||||||
|
/** Strips index signatures to ensure only hard-coded properties are allowed in the union. */
|
||||||
|
export type StrictKeys<T> = keyof {
|
||||||
|
[K in keyof T as string extends K ? never : number extends K ? never : K]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extract only the keys present in T1 that are not in T2 using {@link StrictKeys}. */
|
||||||
|
export type LocalStrictKeys<T1, T2> = Exclude<StrictKeys<T1>, StrictKeys<T2>>;
|
||||||
|
|
||||||
export const Arr = {
|
export const Arr = {
|
||||||
firstOrNull: <T>(a: Array<T>): T | null => a.first() ?? null,
|
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,
|
nonEmpty: <T>(v: T[] | unknown): v is Array<T> => Array.isArray(v) && v.length > 0,
|
||||||
toMutable: <T>(a: readonly T[]): T[] => [...a],
|
toMutable: <T>(a: readonly T[]): T[] => [...a],
|
||||||
} as const;
|
clear: <T>(a: T[]): void => { a.length = 0; },
|
||||||
|
};
|
||||||
|
|
||||||
export const Async = {
|
export const Async = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,22 +49,34 @@ export const Async = {
|
||||||
timeoutPromise
|
timeoutPromise
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export type BoolStr = "true" | "false";
|
||||||
|
|
||||||
|
export const Bln = {
|
||||||
|
is: (value: unknown): value is boolean => typeof value === "boolean",
|
||||||
|
isTrue: (value: unknown): value is boolean => typeof value === "boolean" && value === true,
|
||||||
|
TRUE_STR: "true" as BoolStr,
|
||||||
|
isTrueStr: (value: unknown): boolean => typeof value === "string" && value === Bln.TRUE_STR,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export const Err = {
|
export const Err = {
|
||||||
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
|
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const KeyValue = {
|
export const KeyValue = {
|
||||||
isNotEmpty: <K, V>(v: Map<K, V> | undefined): v is Map<K, V> => v !== undefined && v.size > 0,
|
isNotEmpty: <K, V>(v: Map<K, V> | undefined): v is Map<K, V> => v !== undefined && v.size > 0,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const Num = {
|
export const Num = {
|
||||||
|
|
||||||
is: (value: unknown): value is number => {
|
is: (value: unknown): value is number => {
|
||||||
return typeof value === "number";
|
return typeof value === "number" && !Number.isNaN(value);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isNaN: (value: unknown) => Number.isNaN(value),
|
||||||
|
|
||||||
UInt: {
|
UInt: {
|
||||||
assert: (n: number) => Env.assert(isUnsignedInteger(n)),
|
assert: (n: number) => Env.assert(isUnsignedInteger(n)),
|
||||||
create: (n: number): UnsignedInteger => {
|
create: (n: number): UnsignedInteger => {
|
||||||
|
|
@ -66,7 +87,11 @@ export const Num = {
|
||||||
is: isUnsignedInteger,
|
is: isUnsignedInteger,
|
||||||
} as const,
|
} as const,
|
||||||
|
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
|
export const Null = {
|
||||||
|
is: (value: unknown): value is null => value === null, // typeof value === "object" && !Str.is(value) && !Num.is(value) && !Bln.is(value)
|
||||||
|
};
|
||||||
|
|
||||||
export const Obj = {
|
export const Obj = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,18 +101,85 @@ export const Obj = {
|
||||||
* @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`.
|
* @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`.
|
||||||
*/
|
*/
|
||||||
is: (value: unknown): value is object => {
|
is: (value: unknown): value is object => {
|
||||||
return typeof value === "object" && value !== null; // `null` is an object
|
return typeof value === "object" && value !== null; // In JavaScript runtime, `null` is an object. In TypeScript, with `strictNullChecks`, it is not.
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Trims all string values of top-level properties of {@link obj} in place (non-recursive).
|
||||||
|
*
|
||||||
|
* @param obj The object whose string values to trim. Non-string values are ignored.
|
||||||
|
*/
|
||||||
|
trimValues: (obj: Record<string, unknown>): void => {
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (Str.is(value))
|
||||||
|
obj[key] = value.trim();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param obj
|
||||||
|
* @param key
|
||||||
|
* @param callbacks Optional validation functions. Each receives the property value, the object itself, and the key.
|
||||||
|
* @returns `true` if {@link obj} is an object and the {@link key} exists and all {@link callbacks} pass.
|
||||||
|
*/
|
||||||
|
hasKey: <T extends Record<string, unknown>, K extends StrictKeys<T>>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
key: K,
|
||||||
|
...callbacks: Array<(value: T[K], obj: Record<string, unknown>, key: K) => boolean>
|
||||||
|
): boolean => {
|
||||||
|
|
||||||
|
if (!Obj.is(obj) || !Object.hasOwn(obj, key as PropertyKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return callbacks.every((cb) => cb(obj[key] as T[K], obj, key));
|
||||||
|
},
|
||||||
|
|
||||||
|
getKey: <T extends Record<string, unknown>, K extends StrictKeys<T>>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
key: K
|
||||||
|
): T[K] => {
|
||||||
|
return obj[key] as T[K];
|
||||||
|
},
|
||||||
|
|
||||||
|
setKey: <T extends Record<string, unknown>, K extends StrictKeys<T>>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
key: K,
|
||||||
|
value: T[K]
|
||||||
|
): void => {
|
||||||
|
obj[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Str = {
|
export const Str = {
|
||||||
EMPTY: "",
|
EMPTY: "",
|
||||||
SPACE: " ",
|
SPACE: " ",
|
||||||
NON_BREAKING_SPACE: " ",
|
NON_BREAKING_SPACE: " ",
|
||||||
|
LF: "\n",
|
||||||
|
TAB: "\t",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether `value` is a string.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @returns `true` if `value` is a string, otherwise `false`.
|
||||||
|
*/
|
||||||
is: (value: unknown): value is string => typeof value === "string",
|
is: (value: unknown): value is string => typeof value === "string",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether `value` is a non-empty string.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @returns `true` if `value` is a string with at least one character, otherwise `false`.
|
||||||
|
*/
|
||||||
isNonEmpty: (value: unknown): value is string => typeof value === "string" && value !== "",
|
isNonEmpty: (value: unknown): value is string => typeof value === "string" && value !== "",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `value` if it is a non-empty string, otherwise `undefined`.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @returns The original string if non-empty, or `undefined`.
|
||||||
|
*/
|
||||||
nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined,
|
nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -102,8 +194,50 @@ export const Str = {
|
||||||
toIsoStringOrNull: (date: Date | undefined) => {
|
toIsoStringOrNull: (date: Date | undefined) => {
|
||||||
return date !== undefined ? date.toISOString() : null
|
return date !== undefined ? date.toISOString() : null
|
||||||
},
|
},
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
function isUnsignedInteger(value: unknown): value is UnsignedInteger {
|
function isUnsignedInteger(value: unknown): value is UnsignedInteger {
|
||||||
return Num.is(value) && Number.isInteger(value) && value >= 0;
|
return Num.is(value) && Number.isInteger(value) && value >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A second union member to suppress ESLint's `no-unnecessary-condition` rule from firing. This occurs when TypeScript narrows a union to a single literal (i.e. the union has only one real value), making comparisons appear always true.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Without UNARY_UNION_SUPPRESS, ESLint warns: "comparison is always true, since 'value' === 'value'"
|
||||||
|
* type MyType = "value";
|
||||||
|
*
|
||||||
|
* // With UNARY_UNION_SUPPRESS, the union has two values and ESLint is satisfied:
|
||||||
|
* type MyType = "value" | typeof UNARY_UNION_SUPPRESS;
|
||||||
|
*
|
||||||
|
* // In a switch, add a case for UNARY_UNION_SUPPRESS to satisfy exhaustiveness checking.
|
||||||
|
* // The default case handles truly unexpected values:
|
||||||
|
* switch (myValue) {
|
||||||
|
* case UNARY_UNION_SUPPRESS:
|
||||||
|
* break;
|
||||||
|
* case "value": {
|
||||||
|
* // handle value
|
||||||
|
* break;
|
||||||
|
* }
|
||||||
|
* // Also possible to cover unexpected value if needed, e.g., if value comes from user.
|
||||||
|
* //default:
|
||||||
|
* // throw new Error(`Unexpected value: ${myValue}`);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Once a second real value is added to the union, UNARY_UNION_SUPPRESS is no longer needed and can be removed:
|
||||||
|
* type MyType = "value" | "anotherValue";
|
||||||
|
*
|
||||||
|
* switch (myValue) {
|
||||||
|
* case "value": {
|
||||||
|
* // handle value
|
||||||
|
* break;
|
||||||
|
* }
|
||||||
|
* case "anotherValue": {
|
||||||
|
* // handle anotherValue
|
||||||
|
* break;
|
||||||
|
* }
|
||||||
|
* //default:
|
||||||
|
* // throw new Error(`Unexpected value: ${myValue}`);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const UNARY_UNION_SUPPRESS = "suppress" as const;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
import { DataStore, DataStoreRoot } from "data/DataStore";
|
import { CssClass } from "#/constants";
|
||||||
import { Env } from "env";
|
import { DataStore, DataStoreRoot } from "#/data/DataStore";
|
||||||
|
import { Env } from "#/env";
|
||||||
|
import { ContentRenderer, createRenderConfig } from "#/renderings/content/ContentRenderer";
|
||||||
|
import { PluginSettings, SettingsChanged, SettingsManager } from "#/Settings";
|
||||||
|
import { Icon } from "#/ui/constants";
|
||||||
|
import { Doc, El } from "#/utils/dom/dom";
|
||||||
|
import { ElementCreator } from "#/utils/ElementCreator";
|
||||||
|
import { InteractionAssistant } from "#/utils/obs/InteractionAssistant";
|
||||||
|
import { ViewAssistant } from "#/utils/obs/ViewAssistant";
|
||||||
|
import { Bln } from "#/utils/ts";
|
||||||
import { IconName, 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 { 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 = {
|
export type BaseViewOptionalParameters = {
|
||||||
data?: DataStore;
|
data?: DataStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BaseViewState {
|
export interface BaseViewState {
|
||||||
|
/** {@link BaseView.prototype.setState} only forwards the state to subclasses once unless this is set. Therefore, set this to render based on a new state in an already opened view. Use {@linkcode BaseView.withDefaultViewState}. */
|
||||||
|
forceUpdate?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,10 +25,21 @@ export interface BaseViewEphemeralState {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BaseViewScrollToOptions extends ScrollToOptions { // eslint-disable-line @typescript-eslint/no-empty-object-type
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
|
|
||||||
|
protected static withDefaultViewState<T extends BaseViewState>(state: T): T {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
forceUpdate: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly settingsManager: SettingsManager;
|
protected readonly settingsManager: SettingsManager;
|
||||||
protected readonly contentRenderer: ContentRenderer;
|
protected readonly contentRenderer: ContentRenderer;
|
||||||
|
protected readonly interactionAssistant: InteractionAssistant;
|
||||||
|
|
||||||
private readonly options?: BaseViewOptionalParameters;
|
private readonly options?: BaseViewOptionalParameters;
|
||||||
private readonly viewAssistant: ViewAssistant;
|
private readonly viewAssistant: ViewAssistant;
|
||||||
|
|
@ -38,6 +53,7 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
|
||||||
this.viewAssistant = new ViewAssistant();
|
this.viewAssistant = new ViewAssistant();
|
||||||
|
this.interactionAssistant = new InteractionAssistant(this.app, this, this.viewAssistant);
|
||||||
this.contentRenderer = new ContentRenderer(this.app, createRenderConfig(settingsManager.settings));
|
this.contentRenderer = new ContentRenderer(this.app, createRenderConfig(settingsManager.settings));
|
||||||
this.addChild(this.contentRenderer);
|
this.addChild(this.contentRenderer);
|
||||||
}
|
}
|
||||||
|
|
@ -67,8 +83,10 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.contentEl.empty();
|
this.contentEl.empty();
|
||||||
this.containerEl.addClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
|
||||||
this.viewAssistant.init(this);
|
this.viewAssistant.init(this);
|
||||||
|
this.interactionAssistant.init(Doc.get(this.contentEl));
|
||||||
|
|
||||||
|
El.Cls.add(this.viewAssistant.workspaceLeafEl, CssClass.View.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async onClose(): Promise<void> {
|
protected override async onClose(): Promise<void> {
|
||||||
|
|
@ -82,8 +100,10 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
|
|
||||||
this.contentRenderer.unload(); // Will also be unloaded when this view unloads.
|
this.contentRenderer.unload(); // Will also be unloaded when this view unloads.
|
||||||
this.domFacade = null;
|
this.domFacade = null;
|
||||||
|
|
||||||
|
El.Cls.remove(this.viewAssistant.workspaceLeafEl, CssClass.View.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
||||||
|
this.interactionAssistant.deinit();
|
||||||
this.viewAssistant.deinit();
|
this.viewAssistant.deinit();
|
||||||
this.containerEl.removeClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override onResize(): void {
|
public override onResize(): void {
|
||||||
|
|
@ -98,13 +118,21 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
Env.log.d("BaseView:setState:", this.didSetState, state);
|
Env.log.d("BaseView:setState:", this.didSetState, state);
|
||||||
await super.setState(state, result);
|
await super.setState(state, result);
|
||||||
|
|
||||||
if (!this.didSetState) {
|
const setState = state as State | null | undefined;
|
||||||
this.onSetState(state as State, result);
|
Env.assert(setState !== undefined && setState !== null);
|
||||||
this.didSetState = true;
|
if (setState === undefined || setState === null)
|
||||||
|
return;
|
||||||
|
|
||||||
//this.contentRenderer.recycle();
|
const proceed = async () => {
|
||||||
Env.log.view("BaseView:setState: refreshing view because state was set");
|
this.onSetState(setState, result);
|
||||||
await this.render();
|
await this.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.didSetState) {
|
||||||
|
this.didSetState = true;
|
||||||
|
await proceed();
|
||||||
|
} else if (Bln.isTrue(setState.forceUpdate)) {
|
||||||
|
await proceed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Subclasses should store and manage their own {@link BaseViewState}. {@link onSetState} is only called once per instantiation of this class. */
|
/** Subclasses should store and manage their own {@link BaseViewState}. {@link onSetState} is only called once per instantiation of this class. */
|
||||||
|
|
@ -182,22 +210,24 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
protected abstract onSetState(state: State, result: ViewStateResult): void;
|
protected abstract onSetState(state: State, result: ViewStateResult): void;
|
||||||
/** Supply the state to the system. */
|
/** Supply the state to the system. */
|
||||||
protected abstract onGetState(): State;
|
protected abstract onGetState(): State;
|
||||||
protected onSetEphemeralState(state: unknown): void { };
|
protected onSetEphemeralState(state: unknown): void { }; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
protected onGetEphemeralState(): Record<string, unknown> { return {}; };
|
protected onGetEphemeralState(): Record<string, unknown> { return {}; };
|
||||||
protected abstract onRender(): Promise<void>;
|
protected abstract onRender(): Promise<void>;
|
||||||
|
|
||||||
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void { };
|
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void { }; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void { };
|
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void { }; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
protected get scrollPosition(): { top: number, left: number } {
|
protected get scrollPosition(): { top: number, left: number } {
|
||||||
return this.viewAssistant.scrollContainer
|
return {
|
||||||
? { top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft }
|
top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft
|
||||||
: { top: 0, left: 0 };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setScrollPosition(options: ScrollToOptions) {
|
/** The call is asynchronous, i.e., the scroll position is not updated immediately. */
|
||||||
|
protected setScrollPosition(options: BaseViewScrollToOptions) {
|
||||||
Env.log.d("BaseView:setScrollPosition", options);
|
Env.log.d("BaseView:setScrollPosition", options);
|
||||||
this.viewAssistant.scrollContainer?.scrollTo(options)
|
this.interactionAssistant.nextScrollIsProgrammatic();
|
||||||
|
this.viewAssistant.scrollContainer.scrollTo(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveState() {
|
protected saveState() {
|
||||||
|
|
@ -212,7 +242,7 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||||
this.domFacade = {
|
this.domFacade = {
|
||||||
contentEl: contentEl,
|
contentEl: contentEl,
|
||||||
create: new ElementCreator(contentEl),
|
create: new ElementCreator(contentEl),
|
||||||
doc: getDoc(contentEl),
|
doc: Doc.get(contentEl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.domFacade;
|
return this.domFacade;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export class DecksView extends BaseView<BaseViewState> {
|
||||||
|
|
||||||
public static readonly TYPE = "come-through-view-decks";
|
public static readonly TYPE = "come-through-view-decks";
|
||||||
|
|
||||||
|
public static createViewState(): BaseViewState {
|
||||||
|
return BaseView.withDefaultViewState({});
|
||||||
|
}
|
||||||
|
|
||||||
private readonly data: DataStore;
|
private readonly data: DataStore;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, data: DataStore) {
|
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, data: DataStore) {
|
||||||
|
|
@ -29,7 +33,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
||||||
return "Decks";
|
return "Decks";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onSetState(state: BaseViewState, result: ViewStateResult): void {
|
protected override onSetState(_state: BaseViewState, _result: ViewStateResult): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onGetState(): BaseViewState {
|
protected override onGetState(): BaseViewState {
|
||||||
|
|
@ -78,7 +82,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
||||||
el.createEl("button", { text: "Add" }, (button) => {
|
el.createEl("button", { text: "Add" }, (button) => {
|
||||||
setIcon(button, "plus");
|
setIcon(button, "plus");
|
||||||
setTooltip(button, "Add new deck");
|
setTooltip(button, "Add new deck");
|
||||||
this.contentRenderer.registerDomEvent(button, "click", () => new DeckModal(this.app, this.data, () => { }).open());
|
this.contentRenderer.registerDomEvent(button, "click", () => DeckModal.add(this.app, this.data));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -105,7 +109,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
item.setTitle("Edit");
|
item.setTitle("Edit");
|
||||||
item.setIcon("pen");
|
item.setIcon("pen");
|
||||||
item.onClick(() => new DeckModal(this.app, this.data, () => { }, deck.id).open());
|
item.onClick(() => DeckModal.edit(this.app, this.data, deck.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
|
|
||||||
public static readonly TYPE = "come-through-view-defined-content";
|
public static readonly TYPE = "come-through-view-defined-content";
|
||||||
public static createViewState(file: TFile): DefinedContentViewState {
|
public static createViewState(file: TFile): DefinedContentViewState {
|
||||||
return {
|
return BaseView.withDefaultViewState({
|
||||||
filePath: file.path,
|
filePath: file.path,
|
||||||
};
|
} satisfies DefinedContentViewState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,9 +25,6 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
*/
|
*/
|
||||||
private file: TFile | null | undefined;
|
private file: TFile | null | undefined;
|
||||||
|
|
||||||
/** Keeps track of which details elements have been dynamically created on expand. */
|
|
||||||
private hasRendered: WeakMap<HTMLDetailsElement, boolean>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param data Used to receive data changed notifications.
|
* @param data Used to receive data changed notifications.
|
||||||
*/
|
*/
|
||||||
|
|
@ -55,12 +52,14 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
// Note that the data change callback will also be called on file rename.
|
// Note that the data change callback will also be called on file rename.
|
||||||
// The "rename" event will be called first.
|
// The "rename" event will be called first.
|
||||||
// The difference is that when there's a rename event, we want to update the display text.
|
// The difference is that when there's a rename event, we want to update the display text.
|
||||||
this.registerEvent(this.app.vault.on("rename", this.onFileRename.bind(this)));
|
this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
|
||||||
|
if (file instanceof TFile)
|
||||||
|
this.onFileRename(file, oldPath);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override onunload(): void {
|
public override onunload(): void {
|
||||||
super.onunload();
|
super.onunload();
|
||||||
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
|
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
|
||||||
|
|
@ -76,7 +75,7 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override 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.file = state.filePath ? this.app.vault.getFileByPath(state.filePath) : undefined;
|
||||||
this.contentRenderer.file = this.file;
|
this.contentRenderer.file = this.file;
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +118,9 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
|
|
||||||
// <summary> is styled as a <h2>, this will normalize its children to start at <h3>.
|
// <summary> is styled as a <h2>, this will normalize its children to start at <h3>.
|
||||||
this.contentRenderer.setCustomProcessors([new HeadingProcessor(3)]);
|
this.contentRenderer.setCustomProcessors([new HeadingProcessor(3)]);
|
||||||
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
|
||||||
|
// Keeps track of which details elements have been dynamically created on expand.
|
||||||
|
const hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
||||||
|
|
||||||
this.dom.create.el("h1", { text: t.views.declarations.title(this.file) });
|
this.dom.create.el("h1", { text: t.views.declarations.title(this.file) });
|
||||||
const infoPara = this.dom.create.paraWrapper();
|
const infoPara = this.dom.create.paraWrapper();
|
||||||
|
|
@ -137,19 +138,19 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
|
|
||||||
if (parsedUnit.complete !== null) {
|
if (parsedUnit.complete !== null) {
|
||||||
const completeUnit = parsedUnit.complete;
|
const completeUnit = parsedUnit.complete;
|
||||||
this.createDefinedContentBlockSection(`${completeUnit.frontID.cardID}: Front`, completeUnit.frontMarkdown);
|
this.createDefinedContentBlockSection(hasRendered, `${completeUnit.frontID.cardID}: Front`, completeUnit.frontMarkdown);
|
||||||
this.createDefinedContentBlockSection(`${completeUnit.backID.cardID}: Back`, completeUnit.backMarkdown);
|
this.createDefinedContentBlockSection(hasRendered, `${completeUnit.backID.cardID}: Back`, completeUnit.backMarkdown);
|
||||||
numberOfContentDefinitions += 2;
|
numberOfContentDefinitions += 2;
|
||||||
}
|
}
|
||||||
else if (parsedUnit.incomplete !== null) {
|
else if (parsedUnit.incomplete !== null) {
|
||||||
const incompleteUnit = parsedUnit.incomplete;
|
const incompleteUnit = parsedUnit.incomplete;
|
||||||
|
|
||||||
if (incompleteUnit.frontID) {
|
if (incompleteUnit.frontID) {
|
||||||
this.createDefinedContentBlockSection(`${incompleteUnit.frontID.cardID}: Front (incomplete)`, incompleteUnit.frontMarkdown);
|
this.createDefinedContentBlockSection(hasRendered, `${incompleteUnit.frontID.cardID}: Front (incomplete)`, incompleteUnit.frontMarkdown);
|
||||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||||
}
|
}
|
||||||
if (incompleteUnit.backID) {
|
if (incompleteUnit.backID) {
|
||||||
this.createDefinedContentBlockSection(`${incompleteUnit.backID.cardID}: Back (incomplete)`, incompleteUnit.backMarkdown);
|
this.createDefinedContentBlockSection(hasRendered, `${incompleteUnit.backID.cardID}: Back (incomplete)`, incompleteUnit.backMarkdown);
|
||||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,13 +164,13 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||||
await this.contentRenderer.render(markdownText, infoPara);
|
await this.contentRenderer.render(markdownText, infoPara);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDefinedContentBlockSection(summary: string, content?: string) {
|
private createDefinedContentBlockSection(hasRendered: WeakMap<HTMLDetailsElement, boolean>, summary: string, content?: string) {
|
||||||
this.dom.create.el("details", undefined, (detailsEl) => {
|
this.dom.create.el("details", undefined, (detailsEl) => {
|
||||||
detailsEl.createEl("summary", { text: summary });
|
detailsEl.createEl("summary", { text: summary });
|
||||||
const contentDiv = detailsEl.createDiv();
|
const contentDiv = detailsEl.createDiv();
|
||||||
this.contentRenderer.registerDomEvent(detailsEl, "toggle", async () => {
|
this.contentRenderer.registerDomEvent(detailsEl, "toggle", async () => {
|
||||||
if (detailsEl.open && !this.hasRendered.has(detailsEl)) {
|
if (detailsEl.open && !hasRendered.has(detailsEl)) {
|
||||||
this.hasRendered.set(detailsEl, true);
|
hasRendered.set(detailsEl, true);
|
||||||
if (content)
|
if (content)
|
||||||
await this.contentRenderer.render(content, contentDiv);
|
await this.contentRenderer.render(content, contentDiv);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export const CssClass = {
|
|
||||||
WORKSPACE_LEAF_CONTENT_MODIFIER: "come-through-workspace-leaf-content"
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,26 +1,32 @@
|
||||||
import { ContentParser } from "ContentParser";
|
import { ContentParser } from "#/ContentParser";
|
||||||
import { DataStore, DeckIDDataTuple } from "data/DataStore";
|
import { DataStore, DeckIDDataTuple } from "#/data/DataStore";
|
||||||
import { DeckID } from "data/FullID";
|
import { DeckID } from "#/data/FullID";
|
||||||
import { Env } from "env";
|
import { Env } from "#/env";
|
||||||
import t from "Localization";
|
import t from "#/Localization";
|
||||||
import { ReviewItemInfoModal } from "modals/ReviewItemInfoModal";
|
import { ReviewItemInfoModal } from "#/modals/ReviewItemInfoModal";
|
||||||
|
import { HeadingProcessor } from "#/renderings/content/HeadingProcessor";
|
||||||
|
import { ReviewItemInfo } from "#/scheduling/ReviewItemInfo";
|
||||||
|
import { Scheduler } from "#/scheduling/Scheduler";
|
||||||
|
import { NextReviewItemOptions, Rating, ReviewSortOrder } from "#/scheduling/types";
|
||||||
|
import { SettingsManager } from "#/Settings";
|
||||||
|
import { OmitIndexSignature, UnsignedInteger } from "#/types";
|
||||||
|
import { Icon } from "#/ui/constants";
|
||||||
|
import { UIAssistant } from "#/ui/UIAssistant";
|
||||||
|
import { UnexpectedUndefinedError } from "#/utils/errors";
|
||||||
|
import { FileParserError } from "#/utils/obs/FileParser";
|
||||||
|
import { InternalApi } from "#/utils/obs/internal";
|
||||||
|
import { Bln, 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";
|
||||||
import { IconName, Keymap, KeymapEventListener, Menu, PaneType, Scope, setIcon, setTooltip, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
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";
|
declare global {
|
||||||
import { Scheduler } from "scheduling/Scheduler";
|
interface DOMStringMap {
|
||||||
import { Rating } from "scheduling/types";
|
didAdjustButtons?: import("#/utils/ts").BoolStr;
|
||||||
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 {
|
interface ReviewViewState extends BaseViewState {
|
||||||
deckID: DeckID | null;
|
deckID: DeckID | null;
|
||||||
|
|
@ -51,12 +57,12 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
public static readonly TYPE = "come-through-view-review";
|
public static readonly TYPE = "come-through-view-review";
|
||||||
public static createViewState(deckID: DeckID | null): ReviewViewState {
|
public static createViewState(deckID: DeckID | null): ReviewViewState {
|
||||||
Env.log.d("ReviewView:createViewState: collection ID:", deckID);
|
Env.log.d("ReviewView:createViewState: collection ID:", deckID);
|
||||||
return {
|
return BaseView.withDefaultViewState({
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
...{
|
...{
|
||||||
deckID: deckID,
|
deckID: deckID,
|
||||||
},
|
},
|
||||||
} satisfies ReviewViewState;
|
} satisfies ReviewViewState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly data: DataStore;
|
private readonly data: DataStore;
|
||||||
|
|
@ -177,14 +183,6 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
return this.deck ? `Review: ${this.deck.data.n}` : "Review";
|
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 {
|
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
|
||||||
Env.log.d("ReviewView:onPaneMenu");
|
Env.log.d("ReviewView:onPaneMenu");
|
||||||
super.onPaneMenu(menu, source);
|
super.onPaneMenu(menu, source);
|
||||||
|
|
@ -241,17 +239,40 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
Env.log.d("ReviewView:onOpen");
|
Env.log.d("ReviewView:onOpen");
|
||||||
await super.onOpen();
|
await super.onOpen();
|
||||||
|
|
||||||
|
if (Env.isMobile)
|
||||||
|
this.interactionAssistant.registerDoubleClick(this.contentEl, () => this.displayNextContentItem());
|
||||||
|
|
||||||
const props = this.forwardBackwardButtonProps();
|
const props = this.forwardBackwardButtonProps();
|
||||||
this.displayNextContentButton = this.addAction(props.icon, props.title, () => this.displayNextContentItem());
|
this.displayNextContentButton = this.addAction(props.icon, props.title, () => this.displayNextContentItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onSetState(state: ReviewViewState, result: ViewStateResult): void {
|
protected override onSetState(state: ReviewViewState, _result: ViewStateResult): void {
|
||||||
Env.log.d("ReviewView:onSetState", state);
|
Env.log.d("ReviewView:onSetState", state);
|
||||||
this.state = { ...DEFAULT_STATE, ...state };
|
|
||||||
this.deck = state.deckID ? {
|
const setState = () => {
|
||||||
id: state.deckID,
|
this.state = { ...DEFAULT_STATE, ...state };
|
||||||
data: this.data.getDeck(state.deckID, true)!
|
this.deck = state.deckID ? {
|
||||||
} : null;
|
id: state.deckID,
|
||||||
|
data: this.data.getDeck(state.deckID, true)!
|
||||||
|
} : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Bln.isTrue(state.forceUpdate)) {
|
||||||
|
|
||||||
|
// Makes sense to retain these "settings" when the same leaf is reused.
|
||||||
|
const retainedStateProps = DEFAULT_STATE;
|
||||||
|
retainedStateProps.sortOrder = this.state.sortOrder;
|
||||||
|
retainedStateProps.showMetadata = this.state.showMetadata;
|
||||||
|
|
||||||
|
setState();
|
||||||
|
|
||||||
|
this.state.sortOrder = retainedStateProps.sortOrder;
|
||||||
|
this.state.showMetadata = retainedStateProps.showMetadata;
|
||||||
|
|
||||||
|
this.removeAllPages(true);
|
||||||
|
} else {
|
||||||
|
setState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onGetState(): ReviewViewState {
|
protected override onGetState(): ReviewViewState {
|
||||||
|
|
@ -498,8 +519,11 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
this.refreshUI();
|
this.refreshUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the avalable content from the pager and inserts it into the DOM, replacing the current content.
|
||||||
|
*/
|
||||||
private displayPageAtIndex(index: UnsignedInteger): boolean {
|
private displayPageAtIndex(index: UnsignedInteger): boolean {
|
||||||
Env.log.d("ReviewView:displayContentAtIndex:", index, "review", this.reviewState);
|
Env.log.d("ReviewView:displayPageAtIndex:", index, "review", this.reviewState);
|
||||||
|
|
||||||
Num.UInt.assert(index);
|
Num.UInt.assert(index);
|
||||||
if (!Num.UInt.is(index))
|
if (!Num.UInt.is(index))
|
||||||
|
|
@ -526,7 +550,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
this.dom.contentEl.appendChild(contentToDisplay);
|
this.dom.contentEl.appendChild(contentToDisplay);
|
||||||
}
|
}
|
||||||
else {
|
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.
|
Env.assert(currentContent.parentElement !== null); // 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);
|
this.dom.contentEl.replaceChild(contentToDisplay, currentContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,6 +563,9 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
/**
|
/**
|
||||||
* Removes all cached pages/divs created from the last review unit.
|
* Removes all cached pages/divs created from the last review unit.
|
||||||
*
|
*
|
||||||
|
* Sometimes you want to call render to build additional components, like the info view, without rebuilding the review content.
|
||||||
|
* Even if {@link BaseView.render} removes all content, the created pages are still retained/cached in the {@link pager} and inserted.
|
||||||
|
*
|
||||||
* @param includeEphemeralState Explicitly declare intent to reset ephemeral state
|
* @param includeEphemeralState Explicitly declare intent to reset ephemeral state
|
||||||
*/
|
*/
|
||||||
private removeAllPages(includeEphemeralState: boolean) {
|
private removeAllPages(includeEphemeralState: boolean) {
|
||||||
|
|
@ -579,6 +606,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
return this.pager.currentIndex === 1;
|
return this.pager.currentIndex === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adjusts the UI components (such as buttons, scrollbar) based on the current state. */
|
||||||
private refreshUI() {
|
private refreshUI() {
|
||||||
Env.log.d("ReviewView:refreshUI");
|
Env.log.d("ReviewView:refreshUI");
|
||||||
|
|
||||||
|
|
@ -626,7 +654,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
if (!this.ratingButtonsContainer)
|
if (!this.ratingButtonsContainer)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (this.ratingButtonsContainer.dataset.didAdjustButtons)
|
if (Bln.isTrueStr(this.ratingButtonsContainer.dataset.didAdjustButtons))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// The container needs to be in the DOM otherwise widths may not be available.
|
// The container needs to be in the DOM otherwise widths may not be available.
|
||||||
|
|
@ -638,14 +666,33 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let maxWidth = 0;
|
let maxWidth = 0;
|
||||||
|
let minContentWidth = Infinity;
|
||||||
|
|
||||||
ratingButtons.forEach((btn) => {
|
ratingButtons.forEach((btn) => {
|
||||||
const width = btn.offsetWidth;
|
const width = btn.offsetWidth;
|
||||||
if (width > maxWidth)
|
if (width > maxWidth)
|
||||||
maxWidth = width;
|
maxWidth = width;
|
||||||
|
|
||||||
|
// Find the button whose content/text is of minimum width, this is used to widen the button if the radius is too large.
|
||||||
|
const firstChild = btn.firstElementChild;
|
||||||
|
if (firstChild instanceof HTMLElement && firstChild.offsetWidth < minContentWidth)
|
||||||
|
minContentWidth = firstChild.offsetWidth;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The width of the wides button is now found.
|
||||||
|
// But before setting this width, check that the current radius (set by the Obsidian theme) is not so large that the button becomes a circle.
|
||||||
|
// If the radius is too large, adjust the width to avoid the circular appearance.
|
||||||
|
const firstBtn = ratingButtons[0];
|
||||||
|
if (firstBtn === undefined)
|
||||||
|
throw new UnexpectedUndefinedError();
|
||||||
|
const borderRadius = parseFloat(getComputedStyle(firstBtn).borderRadius);
|
||||||
|
const minDimension = Math.min(maxWidth, firstBtn.offsetHeight);
|
||||||
|
if (borderRadius >= minDimension / 2) // Radius is to large relative to the buttons dimensions. For example, a true circle results from a square button (equal width and height) with a radius of half that size.
|
||||||
|
maxWidth = borderRadius * 2 + (minContentWidth === Infinity ? 2 : minContentWidth) * 0.5; //"borderRadius * 2" is the width that makes it a circle, then whatever is added will make up the horizontal border.
|
||||||
|
|
||||||
ratingButtons.forEach(btn => btn.setCssProps({ "width": maxWidth + "px" }));
|
ratingButtons.forEach(btn => btn.setCssProps({ "width": maxWidth + "px" }));
|
||||||
|
|
||||||
this.ratingButtonsContainer.dataset.didAdjustButtons = "yes";
|
this.ratingButtonsContainer.dataset.didAdjustButtons = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ElementCreator } from "utils/ElementCreator";
|
||||||
import { KeyValue } from "utils/ts";
|
import { KeyValue } from "utils/ts";
|
||||||
import { ReviewState } from "views/review/types";
|
import { ReviewState } from "views/review/types";
|
||||||
|
|
||||||
export function createRatingButtons(nextItemsWithInfo: { item: NextItem, info: ReviewItemInfo }[], reviewState: ReviewState, sortOrder: ReviewSortOrder, cb: (button: HTMLButtonElement, rating: Rating) => void) {
|
export function createRatingButtons(nextItemsWithInfo: { item: NextItem, info: ReviewItemInfo }[], reviewState: ReviewState, _sortOrder: ReviewSortOrder, cb: (button: HTMLButtonElement, rating: Rating) => void) {
|
||||||
Env.log.d("createRatingButtons");
|
Env.log.d("createRatingButtons");
|
||||||
|
|
||||||
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
|
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
|
||||||
|
|
@ -155,7 +155,7 @@ export function createMetadataEl(
|
||||||
}
|
}
|
||||||
|
|
||||||
row.add((col) => {
|
row.add((col) => {
|
||||||
col.add({ text: "Last review" })
|
col.add({ text: "Last reviewed" })
|
||||||
const date = info.lastReview;
|
const date = info.lastReview;
|
||||||
col.add({
|
col.add({
|
||||||
text: date !== null ? DateTime.toString(date) : "This is the first review.",
|
text: date !== null ? DateTime.toString(date) : "This is the first review.",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .r
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phone and tablet-specific line-height */
|
||||||
|
.is-mobile div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons > div > button {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Counter this selector: body.is-phone:not(.is-android:not(.material-off)) button:not(.clickable-icon) */
|
/* 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 {
|
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .view-content .rating-buttons > div > button {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,17 @@ div.come-through-workspace-leaf-content .markdown-reading-view {
|
||||||
div.come-through-workspace-leaf-content :lang(th) {
|
div.come-through-workspace-leaf-content :lang(th) {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Apr 25, 2026: Obsidian zeroes --view-top-spacing for markdown views via:
|
||||||
|
*
|
||||||
|
* .workspace-leaf-content[data-type="markdown"] {
|
||||||
|
* --view-top-spacing: 0;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Custom views have a different data-type and do not get this automatically.
|
||||||
|
* Without it, double top spacing occurs when "Full screen" or "Floating
|
||||||
|
* navigation" is enabled (Appearance, mobile v1.11.6+).
|
||||||
|
*/
|
||||||
|
div.come-through-workspace-leaf-content {
|
||||||
|
--view-top-spacing: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,38 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./src",
|
// Enable to check TS7 compatibility.
|
||||||
|
"stableTypeOrdering": false,
|
||||||
|
|
||||||
|
"rootDir": "./src",
|
||||||
|
"paths": {
|
||||||
|
"*": ["./src/*"],
|
||||||
|
"#/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
"inlineSourceMap": true,
|
"inlineSourceMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"module": "ESNext",
|
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"allowJs": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"importHelpers": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
//"strict": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"DOM",
|
||||||
"ES2022"
|
"ES2022"
|
||||||
]
|
],
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
|
||||||
|
"importHelpers": true,
|
||||||
|
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
|
||||||
|
// For ESLint to handle:
|
||||||
|
"allowUnreachableCode": true,
|
||||||
|
"allowUnusedLabels": true,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./src/**/*.ts"
|
"./src/**/*.ts"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { readFileSync, writeFileSync } from "fs";
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
import process from "process";
|
||||||
|
|
||||||
const targetVersion = process.env.npm_package_version;
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"0.6.1": "1.8.2",
|
||||||
"0.6.0": "1.8.0",
|
"0.6.0": "1.8.0",
|
||||||
"0.5.1": "1.8.0",
|
"0.5.1": "1.8.0",
|
||||||
"0.5.0": "1.8.0",
|
"0.5.0": "1.8.0",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue