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
|
||||
|
||||
## 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
|
||||
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]
|
||||
>
|
||||
> 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 esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
|
||||
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 outdir = prod ? "dist" : ".";
|
||||
const outdir = prod ? "dist" : "dev-vault";
|
||||
|
||||
fs.copyFileSync("manifest.json", path.join(outdir, "manifest.json"));
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
|
|
@ -39,6 +42,7 @@ const context = await esbuild.context({
|
|||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
// Runtime of min supported Obsidian version 1.8.2, see manifest.json
|
||||
target: "es2022",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
|
|
@ -52,7 +56,6 @@ const context = await esbuild.context({
|
|||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
fs.copyFileSync("manifest.json", "dist/manifest.json");
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ export default defineConfig(
|
|||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
projectService: "true",
|
||||
projectService: true,
|
||||
sourceType: "module",
|
||||
ecmaVersion: "latest",
|
||||
ecmaVersion: 2022,
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
|
|
@ -46,22 +45,34 @@ export default defineConfig(
|
|||
}
|
||||
},
|
||||
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",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||
args: "none"
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"args": "all",
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "all",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true,
|
||||
}],
|
||||
|
||||
//
|
||||
"@typescript-eslint/ban-ts-comment": ["error", {
|
||||
"ts-expect-error": false,
|
||||
"ts-ignore": true,
|
||||
"ts-nocheck": true,
|
||||
"ts-check": true,
|
||||
}],
|
||||
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": ["warn", {
|
||||
// https://typescript-eslint.io/rules/no-unnecessary-condition/#only-allowed-literals
|
||||
"allowConstantLoopConditions": "only-allowed-literals"
|
||||
}],
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "come-through",
|
||||
"name": "Come Through",
|
||||
"version": "0.6.0",
|
||||
"minAppVersion": "1.8.0",
|
||||
"version": "0.6.1",
|
||||
"minAppVersion": "1.8.2",
|
||||
"description": "Drill flashcards using spaced repetition.",
|
||||
"author": "mntno",
|
||||
"authorUrl": "https://github.com/mntno",
|
||||
|
|
|
|||
21
package.json
21
package.json
|
|
@ -4,28 +4,29 @@
|
|||
"description": "Drill flashcards using spaced repetition.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"lint": "pnpm eslint .",
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "mntno",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@types/node": "24.6.2",
|
||||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-obsidianmd": "^0.1.4",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "25.6.0",
|
||||
"builtin-modules": "5.1.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"fast-equals": "5.2.2",
|
||||
"globals": "16.4.0",
|
||||
"globals": "17.5.0",
|
||||
"luxon": "3.7.2",
|
||||
"obsidian": "latest",
|
||||
"ts-fsrs": "5.2.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.1"
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1"
|
||||
},
|
||||
"type": "module",
|
||||
"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 { Declaration } from "declarations/Declaration";
|
||||
import { DeclarationParser } from "declarations/DeclarationParser";
|
||||
import { FullSectionRange, SectionRange } from "utils/obs/FileParser";
|
||||
import { CardID, FullID, NoteID } from "data/FullID";
|
||||
import { CardID, FullID, NoteID } from "#/data/FullID";
|
||||
import { DeclarationConstants } from "#/declarations/constants";
|
||||
import { DeclarationParser } from "#/declarations/DeclarationParser";
|
||||
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||
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 { asNoteID, fullIDFromDeclaration } from "TypeAssistant";
|
||||
import { UnexpectedUndefinedError } from "utils/errors";
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
iterationFilter: (idContentInfo) => {
|
||||
|
||||
// For unique ids. If also filtering on noteID, then only this file will be looked at,
|
||||
// while the other side is in another file and thus won't be found.
|
||||
if (idContentInfo.scope == IDScope.UNIQUE)
|
||||
return idContentInfo.id.isCardEqual(id);
|
||||
switch (idContentInfo.scope) {
|
||||
case IDScope.Unique:
|
||||
// For unique ids. If also filtering on noteID, then only this file will be looked at,
|
||||
// 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.
|
||||
if (idContentInfo.scope == IDScope.NOTE)
|
||||
return idContentInfo.id.isEqual(id, true);
|
||||
case IDScope.Note:
|
||||
// Should work for file-scoped IDs.
|
||||
return idContentInfo.id.isEqual(id, true);
|
||||
}
|
||||
|
||||
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
||||
},
|
||||
|
|
@ -142,11 +144,13 @@ export class ContentParser extends DeclarationParser {
|
|||
if (!this.isComplete(maybeParsedCard))
|
||||
return false;
|
||||
|
||||
if (idContentInfo.scope == IDScope.UNIQUE)
|
||||
return maybeParsedCard.frontID.isCardEqual(id);
|
||||
switch (idContentInfo.scope) {
|
||||
case IDScope.Unique:
|
||||
return maybeParsedCard.frontID.isCardEqual(id);
|
||||
|
||||
if (idContentInfo.scope == IDScope.NOTE)
|
||||
return maybeParsedCard.frontID.isEqual(id, true);
|
||||
case IDScope.Note:
|
||||
return maybeParsedCard.frontID.isEqual(id, true);
|
||||
}
|
||||
|
||||
throw new Error(`Unrecognized ID scope for ID: ${idContentInfo.id}`);
|
||||
}
|
||||
|
|
@ -269,7 +273,7 @@ export class ContentParser extends DeclarationParser {
|
|||
|
||||
cardInfos.push({
|
||||
id: id,
|
||||
scope: IDScope.NOTE,
|
||||
scope: IDScope.Note,
|
||||
contentInfo: {
|
||||
range: {
|
||||
start: currentHeading,
|
||||
|
|
@ -287,8 +291,8 @@ export class ContentParser extends DeclarationParser {
|
|||
|
||||
// Look for a declaration in the frontmatter
|
||||
if (cache.frontmatter) {
|
||||
for (const key of Declaration.supportedFrontmatterKeys) {
|
||||
const declaration = CardDeclarationAssistant.fromFrontmatter(cache.frontmatter[key]);
|
||||
for (const key of DeclarationConstants.Frontmatter.KEYS) {
|
||||
const declaration = ContentParser.declarationFromFrontmatter(cache.frontmatter[key]);
|
||||
if (declaration) {
|
||||
cardInfos.push({
|
||||
id: fullIDFromDeclaration(declaration, noteID),
|
||||
|
|
@ -306,7 +310,7 @@ export class ContentParser extends DeclarationParser {
|
|||
|
||||
for (const section of cache.sections ?? []) {
|
||||
|
||||
const declaration = this.getDeclarationFromSection(section, noteID, fileContent);
|
||||
const declaration = ContentParser.getDeclarationFromSection(section, noteID, fileContent);
|
||||
if (!declaration)
|
||||
continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { isString } from "TypeAssistant";
|
|||
import { PLUGIN_NAME } from "ui/constants";
|
||||
|
||||
export interface PluginSettings {
|
||||
/** Trimmed. */
|
||||
uiPrefix: string;
|
||||
hideCardSectionMarker: boolean;
|
||||
hideDeclarationInReadingView: boolean;
|
||||
|
|
@ -47,7 +48,7 @@ const DEFAULT_SCHEDULER: FsrsScheduler = {
|
|||
|
||||
export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void;
|
||||
|
||||
export type SettingsChangedInfo = "schedulerConfig"; // | "x"
|
||||
export type SettingsChangedInfo = "schedulerConfig";// | typeof UNARY_UNION_DEFAULT;
|
||||
|
||||
export class SettingsManager {
|
||||
|
||||
|
|
@ -55,7 +56,7 @@ export class SettingsManager {
|
|||
public settings: PluginSettings;
|
||||
|
||||
/** 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 = {
|
||||
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.save = async (changedInfo?: SettingsChangedInfo) => {
|
||||
await save(this.settings);
|
||||
onSaved?.(changedInfo);
|
||||
onSaved(changedInfo);
|
||||
this.notifyOnChangedListeners(false);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DeckableFullID, FullID, NoteID } from "data/FullID";
|
||||
import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration";
|
||||
import { DeckableFullID, FullID, NoteID } from "#/data/FullID";
|
||||
import { CardDeclarable, ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
|
||||
import { Num, Str } from "#/utils/ts";
|
||||
import { TFile } from "obsidian";
|
||||
import { Num, Obj, Str } from "utils/ts";
|
||||
|
||||
export function asNoteID(value: TFile | string): NoteID {
|
||||
if (value instanceof TFile)
|
||||
|
|
@ -13,18 +13,8 @@ export function asNoteID(value: TFile | string): NoteID {
|
|||
|
||||
export function fullIDFromDeclaration(declaration: CardDeclarable, noteID: NoteID): DeckableFullID | FullID {
|
||||
return declaration.deckID
|
||||
? new DeckableFullID(noteID, declaration.id, CardDeclarationAssistant.isFrontSide(declaration, true), [declaration.deckID])
|
||||
: FullID.create(noteID, declaration.id, CardDeclarationAssistant.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);
|
||||
? new DeckableFullID(noteID, declaration.id, ExplicitDeclarationAssistant.isFrontSide(declaration, true), [declaration.deckID])
|
||||
: FullID.create(noteID, declaration.id, ExplicitDeclarationAssistant.isFrontSide(declaration, true));
|
||||
}
|
||||
|
||||
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 { SelectDeckModal } from "modals/SelectDeckModal";
|
||||
import { App, Command, Keymap, MarkdownView, PaneType, TFile } from "obsidian";
|
||||
import { UIAssistant } from "ui/UIAssistant";
|
||||
import { DecksView } from "views/DecksView";
|
||||
import { DefinedContentView } from "views/DefinedContentView";
|
||||
import { ReviewView } from "views/review/ReviewView";
|
||||
|
|
@ -15,6 +14,7 @@ export const OpenView = {
|
|||
const leaf = app.workspace.getLeaf(paneType);
|
||||
await leaf.setViewState({
|
||||
type: DecksView.TYPE,
|
||||
state: DecksView.createViewState(),
|
||||
active: true,
|
||||
});
|
||||
},
|
||||
|
|
@ -45,21 +45,24 @@ export const OpenView = {
|
|||
};
|
||||
|
||||
const allDecks = dataStore.getAllDecks();
|
||||
if (allDecks.length) {
|
||||
if (allDecks.length > 0) {
|
||||
const modal = new SelectDeckModal(
|
||||
app,
|
||||
dataStore,
|
||||
[...[UIAssistant.allDecksOptionItem()], ...allDecks],
|
||||
async (deck, evt) => {
|
||||
await openView(ReviewView.createViewState(deck.id === UIAssistant.DECK_ID_NONE ? null : deck.id), Keymap.isModEvent(evt));
|
||||
allDecks,
|
||||
(deck, evt) => {
|
||||
openView(
|
||||
ReviewView.createViewState(deck === null ? null : deck.id),
|
||||
Keymap.isModEvent(evt)
|
||||
).catch(console.error);
|
||||
});
|
||||
modal.setPlaceholder(t.modals.selectDeck.placeholder);
|
||||
modal.open();
|
||||
}
|
||||
else {
|
||||
await openView(undefined, paneType);
|
||||
await openView(ReviewView.createViewState(null), paneType);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 { DateTime } from "utils/datetime";
|
||||
import { UnexpectedUndefinedError } from "utils/errors";
|
||||
import { Obj, Str } from "utils/ts";
|
||||
import { Arr, Obj, Str } from "utils/ts";
|
||||
|
||||
export interface DataStoreRoot {
|
||||
decks: DecksData;
|
||||
|
|
@ -15,7 +15,7 @@ export interface DataStoreRoot {
|
|||
|
||||
type DecksData = Record<DeckID, DeckData>;
|
||||
|
||||
interface DeckData {
|
||||
export interface DeckData {
|
||||
/** Name of the deck */
|
||||
n: string;
|
||||
/** Parent decks */
|
||||
|
|
@ -202,6 +202,10 @@ export class DeckEditor {
|
|||
/** Use with {@link DataStore.registerOnChangedCallback} */
|
||||
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 {
|
||||
|
||||
public static readonly DEFAULT_DATA: DataStoreRoot = {
|
||||
|
|
@ -222,6 +226,8 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public cardInfo(id: FullID) {
|
||||
Env.log.data("DataStore:cardInfo: id", id);
|
||||
|
||||
let info = `${id.isFrontSide ? "front" : "back"} of ${id.cardID}`
|
||||
|
||||
const card = this.getCard(id);
|
||||
|
|
@ -234,7 +240,7 @@ export class DataStore {
|
|||
const decks: DeckData[] = [];
|
||||
for (const deckID of card.d) {
|
||||
const deckData = this.getDeck(deckID);
|
||||
console.assert(deckData);
|
||||
console.assert(deckData !== null);
|
||||
if (deckData !== null)
|
||||
decks.push(deckData);
|
||||
}
|
||||
|
|
@ -248,7 +254,7 @@ export class DataStore {
|
|||
* @param cb Return value is ignored.
|
||||
*/
|
||||
public createDeck(cb: (editor: DeckEditor) => unknown): DeckIDDataTuple {
|
||||
|
||||
Env.log.data("DataStore:createDeck");
|
||||
const editor = new DeckEditor(UniqueID.generateID(), {
|
||||
n: "",
|
||||
p: [],
|
||||
|
|
@ -261,18 +267,25 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public editCard(id: FullID, cb: (editor: CardEditor) => boolean) {
|
||||
Env.log.data("DataStore:editCard: id", id);
|
||||
const data = this.getCard(id, true);
|
||||
if (data && cb(new CardEditor(id, data)))
|
||||
this.setDataDirty();
|
||||
}
|
||||
|
||||
public editDeck(id: DeckID, cb: (editor: DeckEditor) => boolean) {
|
||||
const data = this.getDeck(id, true);
|
||||
if (data && cb(new DeckEditor(id, data)))
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
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();
|
||||
return data;
|
||||
}
|
||||
|
||||
public getDeck(id: DeckID, throwIfNotFound = false): DeckData | null {
|
||||
Env.log.data("DataStore:getDeck: id", id);
|
||||
const data = this.data.decks[id] ?? null;
|
||||
if (data === null && throwIfNotFound)
|
||||
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) {
|
||||
Env.log.data("DataStore:deleteDeck: idToDelete", idToDelete, "moveChildrenToID", moveChildrenToID);
|
||||
const data = this.getDeck(idToDelete, throwIfNotFound);
|
||||
if (!data)
|
||||
return null;
|
||||
|
|
@ -312,7 +326,7 @@ export class DataStore {
|
|||
const {
|
||||
predicate,
|
||||
} = options || {};
|
||||
|
||||
Env.log.data("DataStore:getAllDecks: predicate", predicate);
|
||||
const decks: DeckIDDataTuple[] = [];
|
||||
|
||||
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.
|
||||
*/
|
||||
private deleteRemovedCard(id: FullID, throwIfNotFound = false) {
|
||||
Env.log.data("DataStore:deleteRemovedCard: id", id);
|
||||
const removedCard = this.getRemovedCard(id, throwIfNotFound);
|
||||
if (!removedCard)
|
||||
return null;
|
||||
|
||||
const removedNoteData = this.getRemovedNote(id.noteID, throwIfNotFound);
|
||||
Env.assert(removedNoteData);
|
||||
Env.assert(removedNoteData !== null);
|
||||
if (removedNoteData === null)
|
||||
return null;
|
||||
|
||||
|
|
@ -349,7 +364,7 @@ export class DataStore {
|
|||
this.setDataDirty();
|
||||
|
||||
if (StatisticsHelper.isRemovedNoteEmpty(removedNoteData))
|
||||
this.deleteRemovedNote(id.noteID, throwIfNotFound);
|
||||
this.deleteNote("removed", id.noteID, throwIfNotFound);
|
||||
|
||||
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.
|
||||
*/
|
||||
private deleteRemovedCards(removedBeforeDate?: Date) {
|
||||
if (removedBeforeDate) {
|
||||
Env.log.data("DataStore:deleteRemovedCards: removedBeforeDate", removedBeforeDate);
|
||||
if (removedBeforeDate !== undefined) {
|
||||
const time = removedBeforeDate.getTime();
|
||||
this.getAllRemovedCards(undefined, (_cardID: CardID, data: RemovedCardData) => {
|
||||
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;
|
||||
}).forEach(tuple => this.deleteRemovedCard(tuple.id));
|
||||
}
|
||||
|
|
@ -380,18 +396,31 @@ export class DataStore {
|
|||
* @param throwIfExists If card already exist as active.
|
||||
*/
|
||||
private ensureActiveCard(id: FullID, statisticsFactory: () => StatisticsData, throwIfExists = false) {
|
||||
Env.log.data("DataStore:ensureActiveCard: id", id);
|
||||
|
||||
const removedCard = this.getAllRemovedCards(
|
||||
undefined, //(noteID, _) => id.hasNoteID(noteID),
|
||||
// First check if already active in any note.
|
||||
// - 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.
|
||||
).first();
|
||||
));
|
||||
|
||||
this.createActiveNote(id, false);
|
||||
let cardToAdd: CardIDDataTuple;
|
||||
|
||||
if (removedCard) {
|
||||
this.deleteRemovedCard(removedCard.id, true);
|
||||
cardToAdd = StatisticsHelper.toCardIDDataTuple(id, StatisticsHelper.removedCardToCard(removedCard.data));
|
||||
if (removed !== null) {
|
||||
this.deleteRemovedCard(removed.id, true);
|
||||
cardToAdd = StatisticsHelper.toCardIDDataTuple(id, StatisticsHelper.removedCardToCard(removed.data));
|
||||
}
|
||||
else {
|
||||
const deckIDs = id instanceof DeckableFullID ? id.deckIDs : [];
|
||||
|
|
@ -410,7 +439,7 @@ export class DataStore {
|
|||
* @returns The created card or `null` if already existed.
|
||||
*/
|
||||
private addAsActiveCard(card: CardIDDataTuple, throwIfExists = false) {
|
||||
|
||||
Env.log.data("DataStore:addAsActiveCard: card", card);
|
||||
if (this.getCard(card.id, false) !== null) {
|
||||
if (throwIfExists)
|
||||
throw new CardAlreadyExistsError(card.id, []);
|
||||
|
|
@ -432,6 +461,7 @@ export class DataStore {
|
|||
* @returns Returns the note, whether it was created or not.
|
||||
*/
|
||||
private ensureActiveNote(id: FullID) {
|
||||
Env.log.data("DataStore:ensureActiveNote: id", id);
|
||||
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.
|
||||
*/
|
||||
private ensureRemovedNote(noteID: NoteID) {
|
||||
Env.log.data("DataStore:ensureRemovedNote: noteID", noteID);
|
||||
let note = this.getRemovedNote(noteID);
|
||||
if (!note) {
|
||||
note = StatisticsHelper.createRemovedNoteData();
|
||||
|
|
@ -454,6 +485,7 @@ export class DataStore {
|
|||
* @returns The created note or `null` if already existed.
|
||||
*/
|
||||
private createActiveNote(id: FullID, throwIfExists = false): NoteData | null {
|
||||
Env.log.data("DataStore:createActiveNote: id", id);
|
||||
id.throwIfNoNoteID()
|
||||
|
||||
if (this.getNote(id.noteID, false)) {
|
||||
|
|
@ -471,30 +503,55 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public removeNote(noteID: NoteID) {
|
||||
Env.log.data("DataStore:removeNote: noteID", noteID);
|
||||
return this.moveActiveNoteToRemoved(noteID);
|
||||
}
|
||||
|
||||
public async removeAllCards() {
|
||||
Env.log.data("DataStore:removeAllCards");
|
||||
for (const [noteID, note] of Object.entries(this.data.active)) {
|
||||
for (const cardID of Object.keys(note.cs))
|
||||
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);
|
||||
if (!card)
|
||||
return false;
|
||||
if (card === null)
|
||||
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);
|
||||
removedNote.cs[id.cardIDOrThrow()] = StatisticsHelper.cardToRemovedCard(card);
|
||||
const removedItem = StatisticsHelper.cardToRemovedCard(card);
|
||||
|
||||
removedNote.cs[id.cardIDOrThrow()] = removedItem;
|
||||
this.setDataDirty();
|
||||
return true;
|
||||
|
||||
return removedItem;
|
||||
}
|
||||
|
||||
private moveActiveNoteToRemoved(noteID: NoteID, throwIfNotFound = false) {
|
||||
const note = this.deleteActiveNote(noteID, throwIfNotFound);
|
||||
if (!note)
|
||||
Env.log.data("DataStore:moveActiveNoteToRemoved: noteID", noteID);
|
||||
const note = this.deleteNote("active", noteID, throwIfNotFound);
|
||||
if (note === null)
|
||||
return false;
|
||||
|
||||
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.
|
||||
*/
|
||||
private deleteActiveCard(id: FullID, throwIfNotFound = false) {
|
||||
Env.log.data("DataStore:deleteActiveCard: id", id);
|
||||
id.throwIfNoCardID();
|
||||
|
||||
const note = this.getNote(id.noteID, throwIfNotFound);
|
||||
|
|
@ -521,41 +579,50 @@ export class DataStore {
|
|||
|
||||
delete note.cs[id.cardID];
|
||||
if (StatisticsHelper.isNoteEmpty(note))
|
||||
this.deleteActiveNote(id.noteID);
|
||||
this.deleteNote("active", id.noteID);
|
||||
this.setDataDirty();
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes {@link NoteData} with {@link noteID} from {@link DataStoreRoot.active} and returns it.
|
||||
* @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);
|
||||
if (note !== null) {
|
||||
delete this.data.active[noteID];
|
||||
this.setDataDirty();
|
||||
}
|
||||
return note;
|
||||
}
|
||||
* Deletes {@link NoteData} with {@link noteID} from {@link section} and returns it.
|
||||
* @param section
|
||||
* @param noteID
|
||||
* @returns The removed {@link NoteData}, or `null` if {@link noteID} was not found.
|
||||
*/
|
||||
private deleteNote(section: DataSection, noteID: NoteID, throwIfNotFound = false): NoteData | null {
|
||||
Env.log.data(`DataStore:deleteNote: section: ${section}, noteID: ${noteID}`);
|
||||
|
||||
/**
|
||||
* Deletes {@link NoteData} with {@link noteID} from {@link DataStoreRoot.removed} and returns it.
|
||||
* @param noteID
|
||||
* @returns The removed {@link NoteData}, or `null` if {@link noteID} was not found.
|
||||
*/
|
||||
private deleteRemovedNote(noteID: NoteID, throwIfNotFound = false) {
|
||||
const note = this.getRemovedNote(noteID, throwIfNotFound);
|
||||
if (note !== null) {
|
||||
delete this.data.removed[noteID];
|
||||
const actions: SectionActions<NoteData | null> = {
|
||||
active: () => {
|
||||
const n = this.getNote(noteID, section === "active" && throwIfNotFound); // Because `all` calls `active` first.
|
||||
if (n !== null)
|
||||
delete this.data.active[noteID];
|
||||
return n;
|
||||
},
|
||||
removed: () => {
|
||||
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();
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
public getCard(id: FullID, throwIfNotFound = false): CardData | null {
|
||||
Env.log.data("DataStore:getCard: id", id);
|
||||
id.throwIfNoNoteID();
|
||||
id.throwIfNoCardID();
|
||||
|
||||
|
|
@ -572,6 +639,7 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public getNote(noteID: NoteID, throwIfNotFound = false): NoteData | null {
|
||||
Env.log.data("DataStore:getNote: noteID", noteID);
|
||||
const note = this.data.active[noteID] ?? null;
|
||||
if (note === null && throwIfNotFound)
|
||||
throw new Error(`Note with ID "${noteID}" was not found.`);
|
||||
|
|
@ -588,6 +656,7 @@ export class DataStore {
|
|||
* @returns
|
||||
*/
|
||||
private getRemovedNote(noteID: NoteID, throwIfNotFound = false): RemovedNoteData | null {
|
||||
Env.log.data("DataStore:getRemovedNote: noteID", noteID);
|
||||
const note = this.data.removed[noteID] ?? null;
|
||||
if (note === null && throwIfNotFound)
|
||||
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 {
|
||||
Env.log.data("DataStore:getRemovedCard: id", id);
|
||||
const note = this.getRemovedNote(id.noteID, throwIfNotFound);
|
||||
if (!note)
|
||||
return null;
|
||||
|
|
@ -611,8 +681,8 @@ export class DataStore {
|
|||
* @returns
|
||||
*/
|
||||
public getAllCardsForDeck(deckID?: DeckID): CardIDDataTuple[] {
|
||||
Env.log.d("DataStore:getAllCardsForDeck:deckID", deckID);
|
||||
Env.dev?.assert(deckID === undefined || isString(deckID) && deckID !== Env.str.EMPTY, deckID);
|
||||
Env.log.data("DataStore:getAllCardsForDeck: deckID", deckID);
|
||||
Env.dev?.assert(deckID === undefined || isString(deckID) && deckID !== Str.EMPTY, deckID);
|
||||
if (deckID === undefined)
|
||||
return this.getAllCards();
|
||||
|
||||
|
|
@ -629,6 +699,7 @@ export class DataStore {
|
|||
}
|
||||
|
||||
private descendantDecks(parentID?: DeckID): DeckIDDataTuple[] {
|
||||
Env.log.data("DataStore:descendantDecks: parentID", parentID);
|
||||
if (parentID === undefined)
|
||||
return [];
|
||||
|
||||
|
|
@ -645,13 +716,14 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public getAllCards(cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
||||
Env.log.data("DataStore:getAllCards");
|
||||
return this.getAllCardsWithFilters(undefined, cardFilter);
|
||||
}
|
||||
|
||||
private getAllCardsWithFilters(
|
||||
noteFilter?: (noteID: NoteID, data: NoteData) => boolean,
|
||||
cardFilter?: (cardID: CardID, data: CardData) => boolean): CardIDDataTuple[] {
|
||||
|
||||
Env.log.data("DataStore:getAllCardsWithFilters");
|
||||
const cards: CardIDDataTuple[] = [];
|
||||
|
||||
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[] {
|
||||
Env.log.data("DataStore:getAllNotes");
|
||||
if (!noteFilter)
|
||||
return Object.keys(this.data.active).map(k => asNoteID(k));
|
||||
throw new Error("Not Implemented");
|
||||
|
|
@ -676,7 +749,7 @@ export class DataStore {
|
|||
private getAllRemovedCards(
|
||||
noteFilter?: (noteID: NoteID, data: RemovedNoteData) => boolean,
|
||||
cardFilter?: (cardID: CardID, data: RemovedCardData) => boolean) {
|
||||
|
||||
Env.log.data("DataStore:getAllRemovedCards");
|
||||
const cards: RemovedCardIDDataTuple[] = [];
|
||||
|
||||
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.
|
||||
*/
|
||||
public changeNoteID(oldID: NoteID, newID: NoteID, throwIfNotFound = false) {
|
||||
|
||||
Env.log.data("DataStore:changeNoteID: oldID", oldID, "newID", newID);
|
||||
if (this.getNote(newID))
|
||||
throw new Error(`Cannot overwrite ${newID}.`)
|
||||
|
||||
const deletedNote = this.deleteActiveNote(oldID, throwIfNotFound);
|
||||
const deletedNote = this.deleteNote("active", oldID, throwIfNotFound);
|
||||
if (deletedNote) {
|
||||
this.data.active[newID] = deletedNote;
|
||||
this.setDataDirty();
|
||||
|
|
@ -817,7 +890,7 @@ export class DataStore {
|
|||
}
|
||||
|
||||
public async save() {
|
||||
Env.log.d(`DataStore:save: dirty: ${this._isDataDirty}`);
|
||||
Env.log.d("DataStore:save: dirty: ", this._isDataDirty);
|
||||
if (this._isDataDirty) {
|
||||
const purgeRemovedBeforeDate = new Date((new Date()).getTime() - (this.purgeThreshold * 1000));
|
||||
this.deleteRemovedCards(purgeRemovedBeforeDate);
|
||||
|
|
@ -890,6 +963,7 @@ export class DataStore {
|
|||
}
|
||||
|
||||
private triggerDataChanged() {
|
||||
Env.log.data("DataStore:triggerDataChanged", this.registeredChangedCallbacks.length);
|
||||
this.registeredChangedCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(this.data);
|
||||
|
|
@ -902,6 +976,23 @@ export class DataStore {
|
|||
|
||||
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 = {
|
||||
cardsWithoutDeck: (card: CardIDDataTuple) => DataStore.Predicate.cardsInDeck(undefined)(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 NoteID = string;
|
||||
export type IDFilter = (id: FullID) => boolean;
|
||||
|
|
@ -136,7 +138,7 @@ export class FullID implements FullID {
|
|||
* @throws `Error` if {@link cardSide} is not set.
|
||||
*/
|
||||
public get isFrontSide() {
|
||||
console.assert(this.cardSide);
|
||||
Env.assert(this.cardSide !== undefined);
|
||||
if (!this.cardSide)
|
||||
throw new Error(`Side not specified on id: ${this.toString()}`);
|
||||
return this.cardSide === "f" || this.cardSide === "front";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { DataStore, StatisticsData } from "data/DataStore";
|
||||
import { FullID, NoteID } from "data/FullID";
|
||||
import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration";
|
||||
import { DeclarationInfo, DeclarationParser, PostParseInfo } from "declarations/DeclarationParser";
|
||||
import { Env } from "env";
|
||||
import { DataStore, StatisticsData } from "#/data/DataStore";
|
||||
import { FullID, NoteID } from "#/data/FullID";
|
||||
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
|
||||
import { DeclarationInfo, DeclarationParser, PostParseInfo } from "#/declarations/DeclarationParser";
|
||||
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 { asNoteID, isString } from "TypeAssistant";
|
||||
|
||||
/** Keeps the plugin's internal data synchronized with card declarations in the vault by listening for and processing file system events. */
|
||||
export class SyncManager {
|
||||
|
|
@ -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}`);
|
||||
if (this.isSuspended)
|
||||
return;
|
||||
|
|
@ -53,34 +55,34 @@ export class SyncManager {
|
|||
const ids = await SyncManager.processFile(file, this.app);
|
||||
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}`);
|
||||
if (this.isSuspended)
|
||||
return;
|
||||
|
||||
const ids = await SyncManager.processFileChanged(file, data, cache, this.app);
|
||||
await this.syncIDs(ids, file);
|
||||
}
|
||||
};
|
||||
|
||||
public async delete(file: TAbstractFile) {
|
||||
public delete = async (file: TAbstractFile) => {
|
||||
Env.log.d(`SyncManager:delete isSuspended: ${this.isSuspended}`);
|
||||
if (this.isSuspended)
|
||||
return;
|
||||
|
||||
if (file instanceof TFile && this.dataStore.removeNote(asNoteID(file.path)))
|
||||
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}`);
|
||||
if (this.isSuspended)
|
||||
return;
|
||||
|
||||
if (file instanceof TFile && this.dataStore.changeNoteID(asNoteID(oldPath), asNoteID(file), false))
|
||||
await this.dataStore.save();
|
||||
}
|
||||
};
|
||||
|
||||
private static async processFile(file: TFile, app: App) {
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* @param editor
|
||||
|
|
@ -138,8 +140,8 @@ export class SyncManager {
|
|||
cursorPosition.line <= endPosBeforeModification.line)
|
||||
continue;
|
||||
|
||||
const completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, existingIDs);
|
||||
const replacement = CardDeclarationAssistant.toString(completeDeclaration);
|
||||
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
|
||||
const replacement = DeclarationCodec.toYaml(completeDeclaration);
|
||||
|
||||
// This will trigger file changed events.
|
||||
editor.replaceRange(
|
||||
|
|
@ -153,7 +155,7 @@ export class SyncManager {
|
|||
autoGeneratedIDs.push(FullID.create(
|
||||
info.noteID,
|
||||
completeDeclaration.id,
|
||||
CardDeclarationAssistant.isFrontSide(completeDeclaration)
|
||||
ExplicitDeclarationAssistant.isFrontSide(completeDeclaration)
|
||||
));
|
||||
|
||||
diff += replacement.length - (info.location.end - info.location.start);
|
||||
|
|
@ -196,9 +198,9 @@ export class SyncManager {
|
|||
for (const info of replacementDeclarationInfos) {
|
||||
|
||||
const startOffset = info.section.position.start.offset + info.location.start;
|
||||
const replace = CardDeclarationAssistant.toString(info.declaration);
|
||||
const completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, existingIDs);
|
||||
const replacement = CardDeclarationAssistant.toString(completeDeclaration);
|
||||
const replace = DeclarationCodec.toYaml(info.declaration);
|
||||
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
|
||||
const replacement = DeclarationCodec.toYaml(completeDeclaration);
|
||||
|
||||
parts.push(data.slice(sliceStartIndex, startOffset));
|
||||
parts.push(replacement);
|
||||
|
|
@ -216,7 +218,7 @@ export class SyncManager {
|
|||
|
||||
/**
|
||||
* {@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.
|
||||
* @param postInfo As populated by {@link DeclarationParser.getAllIDsInFile} or {@link DeclarationParser.getAllIDsFromMetadata}.
|
||||
|
|
@ -225,10 +227,10 @@ export class SyncManager {
|
|||
private static getFrontSideInvalidDeclarations(postInfo: PostParseInfo) {
|
||||
const declarations = postInfo.incompleteDeclarationInfos
|
||||
.filter(info => (
|
||||
CardDeclarationAssistant.canMakeValidCardDeclarable(info.declaration) &&
|
||||
CardDeclarationAssistant.isFrontSide(info.declaration))
|
||||
ExplicitDeclarationAssistant.canComplete(info.declaration) &&
|
||||
ExplicitDeclarationAssistant.isFrontSide(info.declaration))
|
||||
);
|
||||
console.assert(postInfo.incompleteDeclarationInfos.length == declarations.length, "Hmm")
|
||||
|
||||
return declarations;
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +239,7 @@ export class SyncManager {
|
|||
ids.push(FullID.create(
|
||||
noteID,
|
||||
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>) {
|
||||
let completeDeclaration: CardDeclarable | null = null;
|
||||
|
||||
if (DeclarationParser.isExternalSectionCache(info.section) && info.section.externalType === "frontmatter") {
|
||||
if (CardDeclarationAssistant.isValidCardDeclarable(info.declaration)) {
|
||||
console.assert(false, "Expected an invalid declaration.");
|
||||
completeDeclaration = null; // info.declaration;
|
||||
}
|
||||
else {
|
||||
// Set default values and modify the file.
|
||||
completeDeclaration = CardDeclarationAssistant.makeValidOrThrow(info.declaration, preventIDs);
|
||||
const key = info.section.id;
|
||||
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 {
|
||||
console.assert(key, "Expected section id to be set to the frontmatter YAML key assinged to the declaration.");
|
||||
if (DeclarationParser.isExternalSectionCache(info.section)) {
|
||||
switch (info.section.externalType) {
|
||||
case UNARY_UNION_SUPPRESS:
|
||||
break;
|
||||
|
||||
case "frontmatter": {
|
||||
if (ExplicitDeclarationAssistant.is(info.declaration) && ExplicitDeclarationAssistant.isValid(info.declaration)) {
|
||||
Env.assert(false, "Expected an invalid declaration.");
|
||||
completeDeclaration = null; // info.declaration;
|
||||
}
|
||||
else {
|
||||
// Set default values and modify the file.
|
||||
completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, preventIDs);
|
||||
const key = info.section.id;
|
||||
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}`);
|
||||
if (this.isSuspended)
|
||||
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 {
|
||||
this.dataStore.syncData(ids, file.path, this.statisticsFactory);
|
||||
|
|
|
|||
|
|
@ -1,228 +1,21 @@
|
|||
import { DeckableDeclarable, Declarable, Declaration, DeclarationRange, YamlParseErrorCallback } from "declarations/Declaration";
|
||||
import { isString } from "TypeAssistant";
|
||||
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",
|
||||
};
|
||||
import { CardDeclarable, DeclarationSide, IDScope } from "#/declarations/ExplicitDeclaration";
|
||||
import { DeclarableProperty, NullableStringDeclarableProperty } from "#/declarations/Declarable";
|
||||
|
||||
export class CardDeclaration implements CardDeclarable {
|
||||
[key: string]: DeclarableProperty;
|
||||
public deckID: NullableStringDeclarableProperty;
|
||||
|
||||
public readonly id: string;
|
||||
public readonly side: DeclarationSide;
|
||||
public deckID: string | null;
|
||||
[key: string]: unknown;
|
||||
|
||||
public readonly isAutoGenerated: boolean = false;
|
||||
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.side = side.trim().toLowerCase() as DeclarationSide;
|
||||
this.side = side;
|
||||
this.idScope = idScope;
|
||||
this.deckID = deckID;
|
||||
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 { CommandableDeclarable } from "declarations/CommandDeclaration";
|
||||
import { FileParser, SectionRange } from "utils/obs/FileParser";
|
||||
import { CardDeclaration } from "declarations/CardDeclaration";
|
||||
import { CommandableDeclarable } from "declarations/Commandable";
|
||||
import { IDScope } from "declarations/ExplicitDeclaration";
|
||||
import { CacheItem } from "obsidian";
|
||||
import { FileParser, SectionRange } from "utils/obs/FileParser";
|
||||
|
||||
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;
|
||||
generatedDeclarations: GeneratedContentDeclaration[];
|
||||
}
|
||||
|
|
@ -16,10 +24,26 @@ export type GeneratedContentDeclaration = {
|
|||
range: SectionRange,
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides common functionality for {@link CommandableDeclarable} parsers.
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
||||
extends FileParser
|
||||
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;
|
||||
public generatedDeclarations: GeneratedContentDeclaration[] = [];
|
||||
|
||||
|
|
@ -30,7 +54,12 @@ export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
|||
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({
|
||||
declaration: new CardDeclaration(
|
||||
id,
|
||||
|
|
@ -59,4 +88,11 @@ export abstract class CommandDeclarationParser<T extends CommandableDeclarable>
|
|||
protected get lastID() {
|
||||
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 { CardDeclarationAssistant } from "declarations/CardDeclaration";
|
||||
import { DeckableDeclarable, Declaration } from "declarations/Declaration";
|
||||
import { DeclarationRenderChild } from "renderings/declarations/DeclarationRenderChild";
|
||||
import { DeckModal } from "modals/DeckModal";
|
||||
import { DataStore } from "#/data/DataStore";
|
||||
import { DeckID } from "#/data/FullID";
|
||||
import { CollectionableAssistant, DeckableDeclarable } from "#/declarations/Collectionable";
|
||||
import { DeclarationConstants } from "#/declarations/constants";
|
||||
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";
|
||||
|
||||
/**
|
||||
|
|
@ -13,7 +17,7 @@ import { App, MarkdownPostProcessorContext, MarkdownSectionInformation, TFile, V
|
|||
export class DeclarationManager {
|
||||
|
||||
public static get supportedCodeBlockLanguages() {
|
||||
return Declaration.supportedCodeBlockLanguages;
|
||||
return DeclarationConstants.CodeBlock.LANGUAGES;
|
||||
}
|
||||
|
||||
public static async processCodeBlock(
|
||||
|
|
@ -24,7 +28,8 @@ export class DeclarationManager {
|
|||
data: DataStore) {
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -36,44 +41,49 @@ export class DeclarationManager {
|
|||
app.vault,
|
||||
file,
|
||||
() => ctx.getSectionInfo(el),
|
||||
Declaration.toString(changedDeclaration)
|
||||
DeclarationCodec.toYaml(changedDeclaration)
|
||||
);
|
||||
}
|
||||
|
||||
renderer.render((declaration, type, deckSelectEl) => {
|
||||
|
||||
const file = app.vault.getFileByPath(ctx.sourcePath);
|
||||
console.assert(file);
|
||||
Env.assert(file !== null);
|
||||
if (!file)
|
||||
return;
|
||||
|
||||
if (type === "deckAdded") {
|
||||
DeckModal.add(app, data, async (addedDeck) => {
|
||||
switch (type) {
|
||||
case "deckAdded": {
|
||||
DeckModal.add(app, data, async (addedDeck) => {
|
||||
|
||||
// Add a new option for the created deck
|
||||
deckSelectEl.createEl("option", {
|
||||
text: addedDeck.data.n,
|
||||
value: addedDeck.id,
|
||||
}, (el) => {
|
||||
el.selected = true;
|
||||
// Add a new option for the created deck
|
||||
deckSelectEl.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||
text: addedDeck.data.n,
|
||||
value: addedDeck.id,
|
||||
}, (el) => {
|
||||
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(
|
||||
CardDeclarationAssistant.copyWithDeck(declaration, addedDeck.id),
|
||||
CollectionableAssistant.copyWithDeck(declaration, selectedDeckID),
|
||||
file
|
||||
).catch(console.error);
|
||||
});
|
||||
}
|
||||
else if (type === "deckChanged") {
|
||||
|
||||
const selectedDeckID = deckSelectEl.value ? deckSelectEl.value : null;
|
||||
if (selectedDeckID === declaration.deckID)
|
||||
return;
|
||||
|
||||
handleChangedDeclaration(
|
||||
CardDeclarationAssistant.copyWithDeck(declaration, selectedDeckID),
|
||||
file
|
||||
).catch(console.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { FullID, IDFilter, NoteID } from "data/FullID";
|
||||
import { CardDeclarable, CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
|
||||
import { CommandableDeclarable, CommandDeclarationAssistant } from "declarations/CommandDeclaration";
|
||||
import { Declaration, DeclarationRange } from "declarations/Declaration";
|
||||
import { FullID, IDFilter, NoteID } from "#/data/FullID";
|
||||
import { CommandableAssistant, CommandableDeclarable } from "#/declarations/Commandable";
|
||||
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||
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 { asNoteID, fullIDFromDeclaration } from "TypeAssistant";
|
||||
import { UnexpectedUndefinedError } from "utils/errors";
|
||||
import { FileParser, SectionRange } from "utils/obs/FileParser";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** The declaration candidate. */
|
||||
|
|
@ -45,7 +48,7 @@ interface DeclarationInfoBase {
|
|||
/** The {@link SectionCache} in {@link noteID} where declaration was found. */
|
||||
section: SectionCache;
|
||||
/** The location of the declaration within the {@link section}. */
|
||||
location: DeclarationRange;
|
||||
location: OffsetRange;
|
||||
}
|
||||
|
||||
export class DeclarationParser extends FileParser {
|
||||
|
|
@ -67,7 +70,7 @@ export class DeclarationParser extends FileParser {
|
|||
|
||||
let cache;
|
||||
try {
|
||||
cache = this.fileCacheOrThrow(app, file);
|
||||
cache = this.fileCacheOrThrow(app, file);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
|
|
@ -78,7 +81,7 @@ export class DeclarationParser extends FileParser {
|
|||
|
||||
// Check frontmatter for explicit declaration
|
||||
if (cache.frontmatter) {
|
||||
for (const key of Declaration.supportedFrontmatterKeys)
|
||||
for (const key of DeclarationConstants.Frontmatter.KEYS)
|
||||
if (Object.hasOwn(cache.frontmatter, key))
|
||||
return true;
|
||||
}
|
||||
|
|
@ -94,8 +97,8 @@ export class DeclarationParser extends FileParser {
|
|||
for (const section of cache.sections) {
|
||||
if (this.isCodeSection(section)) {
|
||||
if (fileContent) {
|
||||
const info = this.parseCodeBlock(this.extractContentFromSection(section, fileContent));
|
||||
if (info !== null && Declaration.supportedCodeBlockLanguages.includes(info.language))
|
||||
const info = DeclarationParser.parseCodeBlock(this.extractContentFromSection(section, fileContent));
|
||||
if (info !== null && DeclarationConstants.CodeBlock.isSupportedLanguage(info.language))
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
|
|
@ -164,7 +167,7 @@ export class DeclarationParser extends FileParser {
|
|||
const declaration = this.getDeclarationFromFrontmatter(cache.frontmatter, noteID, parseInfo);
|
||||
if (declaration) {
|
||||
const id = fullIDFromDeclaration(declaration, noteID);
|
||||
if (!checkExistance(id) && (filter === undefined || (filter && filter(id))))
|
||||
if (!checkExistance(id) && (filter === undefined || filter(id)))
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +176,7 @@ export class DeclarationParser extends FileParser {
|
|||
for (const currentHeading of cache.headings ?? []) {
|
||||
const id = this.findFullIDInText(currentHeading.heading, noteID);
|
||||
if (id !== null && !checkExistance(id)) {
|
||||
if (filter === undefined || (filter && filter(id)))
|
||||
if (filter === undefined || filter(id))
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +186,7 @@ export class DeclarationParser extends FileParser {
|
|||
|
||||
const createAndAddIDFromDeclaration = (declaration: CardDeclarable) => {
|
||||
const id = fullIDFromDeclaration(declaration, noteID);
|
||||
if (!checkExistance(id) && (filter === undefined || (filter && filter(id))))
|
||||
if (!checkExistance(id) && (filter === undefined || filter(id)))
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
|
|
@ -236,8 +239,8 @@ export class DeclarationParser extends FileParser {
|
|||
* @returns The first declaration found in {@link frontmatter}.
|
||||
*/
|
||||
protected static getDeclarationFromFrontmatter(frontmatter: FrontMatterCache, noteID: NoteID, parseInfo?: PostParseInfo) {
|
||||
for (const key of Declaration.supportedFrontmatterKeys) {
|
||||
const declaration = CardDeclarationAssistant.fromFrontmatter(frontmatter[key], (incomplete, location) => {
|
||||
for (const key of DeclarationConstants.Frontmatter.KEYS) {
|
||||
const declaration = DeclarationParser.declarationFromFrontmatter(frontmatter[key], (incomplete, location) => {
|
||||
parseInfo?.incompleteDeclarationInfos.push({
|
||||
noteID: noteID,
|
||||
declaration: incomplete,
|
||||
|
|
@ -264,7 +267,7 @@ export class DeclarationParser extends FileParser {
|
|||
|
||||
const source = FileParser.extractContentFromSection(section, fileContent);
|
||||
|
||||
return CardDeclarationAssistant.parseCodeBlock(
|
||||
return DeclarationParser.createExplicitDeclaration(
|
||||
source,
|
||||
(parseError) => {
|
||||
parseInfo?.invalidYaml.push({
|
||||
|
|
@ -273,12 +276,12 @@ export class DeclarationParser extends FileParser {
|
|||
error: parseError
|
||||
});
|
||||
},
|
||||
(incomplete, location) => {
|
||||
(incomplete, range) => {
|
||||
parseInfo?.incompleteDeclarationInfos.push({
|
||||
noteID: noteID,
|
||||
declaration: incomplete,
|
||||
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 parser = CommandDeclarationAssistant.createParser(
|
||||
const parser = DeclarationParser.createCommandDeclarationParser(
|
||||
source,
|
||||
(parseError) => {
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
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 { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "declarations/commands/HeadingsCommandable";
|
||||
import { CacheItem, HeadingCache } from "obsidian";
|
||||
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||
import { Commands, CommandName } from "#/declarations/CommandNames";
|
||||
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 {
|
||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||
return (
|
||||
AlternateHeadingsAssistant.conforms<AlternateHeadingsDeclarable>(commandable) &&
|
||||
AlternateHeadingsAssistant.isValid(commandable)
|
||||
) ? new AlternateHeadingsParser(commandable) : null;
|
||||
|
||||
public static override is(value: unknown): value is AlternateHeadingsDeclarable {
|
||||
if (!HeadingsCommandableAssistant.is(value))
|
||||
return false;
|
||||
|
||||
return (Commands.Name.AlternateHeadings as readonly CommandName[]).includes(value.name);
|
||||
}
|
||||
}
|
||||
const ThisAssistant = AlternateHeadingsAssistant;
|
||||
|
||||
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[]) {
|
||||
|
||||
if (!AlternateHeadingsParser.isHeadingCache(inBetweenDelimiter))
|
||||
if (!FileParser.isHeadingCache(inBetweenDelimiter))
|
||||
return;
|
||||
|
||||
// Only interested in headings on the specified level
|
||||
|
|
@ -26,10 +40,31 @@ export class AlternateHeadingsParser extends HeadingsDeclarationParser<Alternate
|
|||
return;
|
||||
|
||||
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(
|
||||
isFront ? inBetweenDelimiter.heading : this.lastID,
|
||||
id,
|
||||
isFront,
|
||||
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/CommandDeclaration";
|
||||
import { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable } from "declarations/commands/HeadingsCommandable";
|
||||
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||
import { CommandDeclarationParsable, CommandDeclarationParser } from "#/declarations/CommandDeclarationParser";
|
||||
import { CommandName, Commands } from "#/declarations/CommandNames";
|
||||
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 { UnexpectedUndefinedError } from "utils/errors";
|
||||
|
||||
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 {
|
||||
|
||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||
return (
|
||||
HeadingAndDelimiterAssistant.conforms<HeadingAndDelimiterDeclarable>(commandable) &&
|
||||
HeadingAndDelimiterAssistant.isValid(commandable)
|
||||
) ? new HeadingAndDelimiterParser(commandable) : null;
|
||||
public static override is(value: unknown): value is HeadingAndDelimiterDeclarable {
|
||||
if (!HeadingsCommandableAssistant.is(value))
|
||||
return false;
|
||||
|
||||
return (Commands.Name.HeadingAndDelimiter as readonly CommandName[]).includes(value.name);
|
||||
}
|
||||
|
||||
public static isValid(command: HeadingAndDelimiterDeclarable) {
|
||||
return (
|
||||
super.isValid(command) &&
|
||||
this.isDelimiterValid(command)
|
||||
);
|
||||
public static override isValid(declarable: HeadingAndDelimiterDeclarable) {
|
||||
if (!HeadingsCommandableAssistant.isValid(declarable))
|
||||
return false;
|
||||
|
||||
return ThisAssistant.isDelimiterValid(declarable);
|
||||
}
|
||||
|
||||
private static isDelimiterValid(command: HeadingAndDelimiterDeclarable) {
|
||||
|
|
@ -32,6 +35,7 @@ export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
|||
switch (command.delimiter as string) {
|
||||
case "hr":
|
||||
setDefault();
|
||||
return true;
|
||||
case "horizontal rule":
|
||||
return true;
|
||||
}
|
||||
|
|
@ -44,12 +48,17 @@ export class HeadingAndDelimiterAssistant extends HeadingsCommandableAssistant {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
const ThisAssistant = HeadingAndDelimiterAssistant;
|
||||
|
||||
export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingAndDelimiterDeclarable> {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public static tryCreate(declarable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||
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;
|
||||
|
||||
public parse(parentHeadingLevel: number, inBetweenDelimiter: CacheItem, index: number, delimiters: CacheItem[]) {
|
||||
|
|
@ -63,17 +72,17 @@ export class HeadingAndDelimiterParser extends CommandDeclarationParser<HeadingA
|
|||
}
|
||||
|
||||
let id: string;
|
||||
let idScope;
|
||||
let idScope: IDScope;
|
||||
|
||||
if (isDelimiterHeading) {
|
||||
const uniqueID = this.tryParseUniqueID(inBetweenDelimiter.heading);
|
||||
if (uniqueID) {
|
||||
if (uniqueID !== null) {
|
||||
id = uniqueID;
|
||||
idScope = IDScope.UNIQUE;
|
||||
idScope = IDScope.Unique;
|
||||
}
|
||||
else {
|
||||
id = inBetweenDelimiter.heading;
|
||||
idScope = IDScope.NOTE;
|
||||
idScope = IDScope.Note;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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 { CommandDeclarationParsable, CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "declarations/commands/HeadingsCommandable";
|
||||
import { CacheItem, HeadingCache, Loc } from "obsidian";
|
||||
import { CommandableDeclarable } from "#/declarations/Commandable";
|
||||
import { CommandDeclarationParsable } from "#/declarations/CommandDeclarationParser";
|
||||
import { CommandName, Commands } from "#/declarations/CommandNames";
|
||||
import { HeadingsCommandableAssistant, HeadingsCommandableDeclarable, HeadingsDeclarationParser } from "#/declarations/commands/HeadingsCommandable";
|
||||
import { IDScope } from "#/declarations/ExplicitDeclaration";
|
||||
import { CacheItem, Loc } from "obsidian";
|
||||
|
||||
export interface HeadingIsFrontDeclarable extends HeadingsCommandableDeclarable { }
|
||||
|
||||
export class HeadingIsFrontAssistant extends HeadingsCommandableAssistant {
|
||||
public static tryCreateParser(commandable: CommandableDeclarable): CommandDeclarationParsable | null {
|
||||
return (
|
||||
HeadingIsFrontAssistant.conforms<HeadingIsFrontDeclarable>(commandable) &&
|
||||
HeadingIsFrontAssistant.isValid(commandable)
|
||||
) ? new HeadingIsFrontParser(commandable) : null;
|
||||
}
|
||||
export interface HeadingIsFrontDeclarable extends HeadingsCommandableDeclarable { // eslint-disable-line @typescript-eslint/no-empty-object-type
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
|
||||
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[]) {
|
||||
if (!HeadingIsFrontParser.isHeadingCache(inBetweenDelimiter))
|
||||
return;
|
||||
|
|
@ -24,7 +36,15 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
|||
if (!this.isOnSpecifiedLevel(parentHeadingLevel, inBetweenDelimiter))
|
||||
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 = {
|
||||
line: inBetweenDelimiter.position.start.line,
|
||||
col: inBetweenDelimiter.position.start.col,
|
||||
|
|
@ -50,7 +70,9 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
|||
start: endLocation,
|
||||
end: endLocation,
|
||||
}
|
||||
});
|
||||
},
|
||||
idScope,
|
||||
);
|
||||
|
||||
this.generateDeclaration(
|
||||
id,
|
||||
|
|
@ -61,7 +83,8 @@ export class HeadingIsFrontParser extends HeadingsDeclarationParser<HeadingIsFro
|
|||
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 { CommandDeclarationParser } from "declarations/CommandDeclarationParser";
|
||||
import { CommandableAssistant, CommandableDeclarable } from "#/declarations/Commandable";
|
||||
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 { isNumber } from "TypeAssistant";
|
||||
import { UnexpectedUndefinedError } from "utils/errors";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export interface HeadingsCommandableDeclarable extends CommandableDeclarable {
|
||||
level: number;
|
||||
* A command declaration that uses headings as dividers.
|
||||
* @abstract
|
||||
*/
|
||||
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 {
|
||||
return Object.hasOwn(command, "level");
|
||||
type PropertyNames = LocalStrictKeys<DefaultableHeadingsCommandableDeclarable, CommandableDeclarable>;
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param command
|
||||
* @returns `true` if the values of properties were valid or undefined, in which case default values were set.
|
||||
*/
|
||||
public static isValid(command: HeadingsCommandableDeclarable) {
|
||||
return (
|
||||
this.isLevelValid(command)
|
||||
);
|
||||
public static override isValid(declarable: HeadingsCommandableDeclarable) {
|
||||
if (!This.Defaultable.isValid(declarable))
|
||||
return false;
|
||||
|
||||
if (declarable.level < 1)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static isLevelValid(command: HeadingsCommandableDeclarable) {
|
||||
if (isNumber(command.level)) {
|
||||
return command.level >= 1;
|
||||
}
|
||||
public static readonly Defaultable = {
|
||||
|
||||
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 false;
|
||||
}
|
||||
}
|
||||
const This = HeadingsCommandableAssistant;
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDeclarable>
|
||||
extends CommandDeclarationParser<T> {
|
||||
|
||||
|
|
@ -66,7 +98,7 @@ export abstract class HeadingsDeclarationParser<T extends HeadingsCommandableDec
|
|||
for (let nextIndex = index + 1; nextIndex < delimiters.length; nextIndex++) {
|
||||
const nextDelimiter = delimiters[nextIndex];
|
||||
if (nextDelimiter === undefined)
|
||||
throw new UnexpectedUndefinedError();
|
||||
throw new UnexpectedUndefinedError();
|
||||
if (HeadingsDeclarationParser.isHeadingCache(nextDelimiter) && headingLevel >= nextDelimiter.level)
|
||||
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";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
const isDev = !isProduction;
|
||||
const noop = () => {};
|
||||
|
||||
const noopLogger = {
|
||||
debug: noop,
|
||||
log: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
const noopLogger: Pick<Console, "debug" | "log" | "info" | "warn" | "error"> = {
|
||||
debug: () => {},
|
||||
log: () => { },
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
const devLogger = isDev ? console : noopLogger;
|
||||
|
|
@ -22,7 +22,6 @@ export const Env = {
|
|||
/** Debug/Dev context */
|
||||
dev: isDev ? DevContext : undefined,
|
||||
isDev: isDev,
|
||||
noop: noop,
|
||||
|
||||
/** Always logs */
|
||||
error: console.error,
|
||||
|
|
@ -53,11 +52,12 @@ export const Env = {
|
|||
e: console.error,
|
||||
|
||||
/** Debug log for content processors. See {@link ContentRendererProcessor}. */
|
||||
proc: devLogger.info, // devLogger.info : noopLogger.debug,
|
||||
proc: noopLogger.info,
|
||||
|
||||
/** Debug log for views. */
|
||||
view: devLogger.info, // devLogger.info : noopLogger.debug,
|
||||
view: noopLogger.info,
|
||||
|
||||
data: noopLogger.info,
|
||||
},
|
||||
|
||||
perf: {
|
||||
|
|
@ -78,12 +78,13 @@ export const Env = {
|
|||
},
|
||||
|
||||
/** 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. */
|
||||
isTablet: Platform.isTablet,
|
||||
get isTablet(): boolean {
|
||||
return Platform.isTablet;
|
||||
},
|
||||
|
||||
|
||||
str: {
|
||||
EMPTY: "",
|
||||
} as const
|
||||
} as const;
|
||||
|
|
|
|||
98
src/main.ts
98
src/main.ts
|
|
@ -1,23 +1,24 @@
|
|||
import { OpenView, OpenViewCommand } from "commands/openView";
|
||||
import { ReviewViewCommand } from "commands/reviewView";
|
||||
import { DataStore, DataStoreRoot } from "data/DataStore";
|
||||
import { SyncManager } from "data/SyncManager";
|
||||
import { UniqueID } from "data/UniqueID";
|
||||
import { DeclarationManager } from "declarations/DeclarationManager";
|
||||
import { DeclarationParser } from "declarations/DeclarationParser";
|
||||
import { Env } from "env";
|
||||
import t from "Localization";
|
||||
import { ConfirmationModal } from "modals/ConfirmationModal";
|
||||
import { Editor, Keymap, MarkdownPostProcessorContext, MarkdownView, Plugin, TFile } from "obsidian";
|
||||
import { Scheduler } from "scheduling/Scheduler";
|
||||
import { FsrsSchedulerConfig } from "scheduling/types";
|
||||
import { PluginSettings, SettingsChangedInfo, SettingsManager } from "Settings";
|
||||
import { Icon } from "ui/constants";
|
||||
import { SettingTab } from "ui/SettingTab";
|
||||
import { UIAssistant } from "ui/UIAssistant";
|
||||
import { DecksView } from "views/DecksView";
|
||||
import { DefinedContentView } from "views/DefinedContentView";
|
||||
import { ReviewView } from "views/review/ReviewView";
|
||||
import { OpenView, OpenViewCommand } from "#/commands/openView";
|
||||
import { ReviewViewCommand } from "#/commands/reviewView";
|
||||
import { DataStore, DataStoreRoot } from "#/data/DataStore";
|
||||
import { SyncManager } from "#/data/SyncManager";
|
||||
import { DeclarationManager } from "#/declarations/DeclarationManager";
|
||||
import { DeclarationParser } from "#/declarations/DeclarationParser";
|
||||
import { Env } from "#/env";
|
||||
import t from "#/Localization";
|
||||
import { ConfirmationModal } from "#/modals/ConfirmationModal";
|
||||
import { Scheduler } from "#/scheduling/Scheduler";
|
||||
import { FsrsSchedulerConfig } from "#/scheduling/types";
|
||||
import { PluginSettings, SettingsChangedInfo, SettingsManager } from "#/Settings";
|
||||
import { Icon } from "#/ui/constants";
|
||||
import { SettingTab } from "#/ui/SettingTab";
|
||||
import { UIAssistant } from "#/ui/UIAssistant";
|
||||
import { DomState } from "#/utils/obs/DomState";
|
||||
import { DecksView } from "#/views/DecksView";
|
||||
import { DefinedContentView } from "#/views/DefinedContentView";
|
||||
import { ReviewView } from "#/views/review/ReviewView";
|
||||
import { EditorCommand } from "commands/editor";
|
||||
import { Keymap, MarkdownPostProcessorContext, Plugin, TFile } from "obsidian";
|
||||
|
||||
interface PluginData {
|
||||
settings: PluginSettings;
|
||||
|
|
@ -25,16 +26,16 @@ interface PluginData {
|
|||
}
|
||||
|
||||
export default class ComeThroughPlugin extends Plugin {
|
||||
private dataStore: DataStore;
|
||||
private scheduler: Scheduler;
|
||||
private settingsManager: SettingsManager;
|
||||
private syncManager: SyncManager;
|
||||
private ui: UIAssistant;
|
||||
private dataStore!: DataStore;
|
||||
private scheduler!: Scheduler;
|
||||
private settingsManager!: SettingsManager;
|
||||
private syncManager!: SyncManager;
|
||||
private ui!: UIAssistant;
|
||||
|
||||
/** Must referene the latest data before {@link savePluginData} is called. Only needed to hold references in order to pass to {@link Plugin.saveData}, see {@link savePluginData}. */
|
||||
private latestPluginDataRef: PluginData;
|
||||
/** 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;
|
||||
|
||||
public async onload() {
|
||||
public override async onload() {
|
||||
|
||||
this.latestPluginDataRef = await ComeThroughPlugin.loadPluginData(this);
|
||||
|
||||
|
|
@ -44,8 +45,9 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
this.latestPluginDataRef.settings = settings;
|
||||
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.latestPluginDataRef.data = data;
|
||||
|
|
@ -101,22 +103,19 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
this.addCommand(ReviewViewCommand.toggleInlineInfo(this.app));
|
||||
this.addCommand(ReviewViewCommand.navigateToSourceFile(this.app));
|
||||
|
||||
this.addCommand({
|
||||
id: 'generate-id-cursor',
|
||||
name: t.commands.generateId.name,
|
||||
editorCallback: (editor: Editor, _view: MarkdownView) => {
|
||||
editor.replaceRange(UniqueID.generateID(), editor.getCursor())
|
||||
}
|
||||
});
|
||||
this.addCommand(EditorCommand.generateId());
|
||||
this.addCommand(EditorCommand.insertReviewUnit(false));
|
||||
this.addCommand(EditorCommand.insertReviewUnit(true));
|
||||
}
|
||||
|
||||
public onunload() {
|
||||
if (this.confirmationModal)
|
||||
public override onunload() {
|
||||
DomState.deinit();
|
||||
if (this.confirmationModal !== null)
|
||||
this.confirmationModal.forceClose();
|
||||
}
|
||||
|
||||
/** 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");
|
||||
|
||||
const overwrittenDataOnDisk = await ComeThroughPlugin.loadPluginData(this);
|
||||
|
|
@ -152,9 +151,9 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
if (info.activeChanged || info.collectionsChanged) {
|
||||
|
||||
// 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.onClosed = () => this.confirmationModal = undefined;
|
||||
this.confirmationModal.onClosed = () => this.confirmationModal = null;
|
||||
this.confirmationModal.open();
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +171,7 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
);
|
||||
});
|
||||
}
|
||||
private confirmationModal?: ConfirmationModal;
|
||||
private confirmationModal: ConfirmationModal | null = null;
|
||||
|
||||
// public onUserEnable(): void {}
|
||||
|
||||
|
|
@ -182,10 +181,10 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
*/
|
||||
private registerEvents() {
|
||||
|
||||
this.registerEvent(this.app.workspace.on("file-open", this.syncManager.open.bind(this.syncManager)));
|
||||
this.registerEvent(this.app.metadataCache.on("changed", this.syncManager.changed.bind(this.syncManager)));
|
||||
this.registerEvent(this.app.vault.on("delete", this.syncManager.delete.bind(this.syncManager)));
|
||||
this.registerEvent(this.app.vault.on("rename", this.syncManager.rename.bind(this.syncManager)));
|
||||
this.registerEvent(this.app.workspace.on("file-open", this.syncManager.open));
|
||||
this.registerEvent(this.app.metadataCache.on("changed", this.syncManager.changed));
|
||||
this.registerEvent(this.app.vault.on("delete", this.syncManager.delete));
|
||||
this.registerEvent(this.app.vault.on("rename", this.syncManager.rename));
|
||||
|
||||
this.registerEvent(this.app.workspace.on("file-menu", (menu, file, source, _leaf) => {
|
||||
if (!(file instanceof TFile))
|
||||
|
|
@ -236,17 +235,18 @@ export default class ComeThroughPlugin extends Plugin {
|
|||
await this.saveData(this.latestPluginDataRef);
|
||||
}
|
||||
|
||||
private onSettingsSaved(changedInfo?: SettingsChangedInfo) {
|
||||
private onSettingsSaved = (changedInfo?: SettingsChangedInfo) => {
|
||||
switch (changedInfo) {
|
||||
case "schedulerConfig":
|
||||
this.scheduler.reconfigure(this.createSchedulerConfig());
|
||||
break;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private createSchedulerConfig() {
|
||||
const config = this.settingsManager.defaultScheduler.config;
|
||||
Env.assert(config);
|
||||
return {
|
||||
enableFuzz: config.enableFuzz,
|
||||
} 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 { CssClass } from "utils/obs/constants";
|
||||
|
||||
/**
|
||||
* - 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.
|
||||
// If modal contains a lot of content and therefore expands in height, it will never fill the entire screen. So in those cases, it's better to force fullscreen.
|
||||
if (options?.fullscreenOnLimitedScreenSpace && Env.isPhone)
|
||||
this.modalEl.addClass(CssClass.Modal.LG);
|
||||
this.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 }) {
|
||||
const s = new Setting(this.contentEl).setName(name)
|
||||
if (o?.styleControlElAsDesc)
|
||||
s.controlEl.addClass(CssClass.Setting.Item.DESC);
|
||||
s.controlEl.addClass(ObsCssClass.Setting.Item.DESC);
|
||||
if (o?.isHeading)
|
||||
s.setHeading();
|
||||
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`. */
|
||||
protected getButtonContainer() {
|
||||
// Try to find existing button container, or create one if it doesn't exist
|
||||
let buttonContainer = this.modalEl.querySelector<HTMLElement>("." + CssClass.Modal.BUTTON_CONTAINER);
|
||||
let buttonContainer = this.modalEl.querySelector<HTMLElement>("." + ObsCssClass.Modal.BUTTON_CONTAINER);
|
||||
if (buttonContainer === null)
|
||||
buttonContainer = this.contentEl.createDiv(CssClass.Modal.BUTTON_CONTAINER);
|
||||
buttonContainer = this.contentEl.createDiv(ObsCssClass.Modal.BUTTON_CONTAINER);
|
||||
return buttonContainer;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ export class BaseModal extends Modal {
|
|||
|
||||
this.getButtonContainer().createEl("button", {
|
||||
text: "Done",
|
||||
cls: CssClass.Modal.CANCEL,
|
||||
cls: ObsCssClass.Modal.CANCEL,
|
||||
}, (button => {
|
||||
button.addEventListener("click", () => this.close());
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,116 +1,132 @@
|
|||
import { DataStore, DeckEditor, DeckIDDataTuple } from "data/DataStore";
|
||||
import { DeckID } from "data/FullID";
|
||||
import { BaseModal } from "modals/BaseModal";
|
||||
import { DataStore, DeckEditor, DeckIDDataTuple } from "#/data/DataStore";
|
||||
import { DeckID } from "#/data/FullID";
|
||||
import { BaseModal } from "#/modals/BaseModal";
|
||||
import { HtmlTag } from "#/utils/dom/constants";
|
||||
import { Str } from "#/utils/ts";
|
||||
import { App, ButtonComponent, Setting } from "obsidian";
|
||||
import { UIAssistant } from "ui/UIAssistant";
|
||||
|
||||
export type OnSubmitCallback = (deck: DeckIDDataTuple) => void;
|
||||
|
||||
export class DeckModal extends BaseModal {
|
||||
|
||||
private nameOfDeck: string = "";
|
||||
private parentDeckID: DeckID | null;
|
||||
private createButton: ButtonComponent;
|
||||
private result?: DeckIDDataTuple;
|
||||
private nameOfDeck: string = Str.EMPTY;
|
||||
private parentDeckID: DeckID | null = null;
|
||||
private createButton!: ButtonComponent;
|
||||
private result: DeckIDDataTuple | undefined;
|
||||
|
||||
public static add(app: App,
|
||||
data: DataStore,
|
||||
onAdded: (deck: DeckIDDataTuple) => void) {
|
||||
new DeckModal(app, data, onAdded).open();
|
||||
}
|
||||
private readonly data: DataStore;
|
||||
private readonly onSubmit: OnSubmitCallback | undefined;
|
||||
private readonly idToEdit: DeckID | undefined;
|
||||
|
||||
constructor(
|
||||
readonly app: App,
|
||||
private readonly data: DataStore,
|
||||
private readonly onSubmit: (deck: DeckIDDataTuple) => void,
|
||||
private readonly idToEdit?: DeckID) {
|
||||
super(app, { fullscreenOnLimitedScreenSpace: true });
|
||||
public static add(
|
||||
app: App,
|
||||
data: DataStore,
|
||||
onAdded?: OnSubmitCallback) {
|
||||
new DeckModal(app, data, onAdded).open();
|
||||
}
|
||||
|
||||
if (idToEdit) {
|
||||
const deckToEdit = data.getDeck(idToEdit, true)!;
|
||||
this.nameOfDeck = deckToEdit.n;
|
||||
this.parentDeckID = DeckEditor.parent(deckToEdit);
|
||||
this.setTitle("Edit deck");
|
||||
}
|
||||
else {
|
||||
this.setTitle("Create a new deck");
|
||||
}
|
||||
public static edit(
|
||||
app: App,
|
||||
data: DataStore,
|
||||
id: DeckID,
|
||||
onSubmit?: OnSubmitCallback) {
|
||||
new DeckModal(app, data, onSubmit, id).open();
|
||||
}
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName("Name")
|
||||
.addText((component) => {
|
||||
component.setValue(this.nameOfDeck);
|
||||
component.onChange((text) => {
|
||||
this.nameOfDeck = text;
|
||||
this.createButton.setDisabled(this.nameOfDeck.trim().length == 0);
|
||||
});
|
||||
});
|
||||
private constructor(
|
||||
app: App,
|
||||
data: DataStore,
|
||||
onSubmit?: OnSubmitCallback,
|
||||
idToEdit?: DeckID
|
||||
) {
|
||||
super(app, { fullscreenOnLimitedScreenSpace: true });
|
||||
this.data = data;
|
||||
this.onSubmit = onSubmit;
|
||||
this.idToEdit = idToEdit;
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.setName("Parent deck")
|
||||
.setDesc(idToEdit ? "" : "To make this deck a subdeck, choose a parent deck.")
|
||||
.addDropdown((component) => {
|
||||
if (idToEdit) {
|
||||
const deckToEdit = data.getDeck(idToEdit, true)!;
|
||||
this.nameOfDeck = deckToEdit.n;
|
||||
this.parentDeckID = DeckEditor.parent(deckToEdit);
|
||||
this.setTitle("Edit deck");
|
||||
}
|
||||
else {
|
||||
this.setTitle("Create a new deck");
|
||||
}
|
||||
|
||||
component.addOption(UIAssistant.DECK_ID_NONE, "None");
|
||||
for (const deck of this.data.getAllDecks().filter(d => d.id !== this.idToEdit))
|
||||
component.addOption(deck.id, deck.data.n);
|
||||
component.setValue(this.parentDeckID ?? UIAssistant.DECK_ID_NONE);
|
||||
new Setting(this.contentEl)
|
||||
.setName("Name")
|
||||
.addText((component) => {
|
||||
component.setValue(this.nameOfDeck);
|
||||
component.onChange((text) => {
|
||||
this.nameOfDeck = text;
|
||||
this.createButton.setDisabled(this.nameOfDeck.trim().length == 0);
|
||||
});
|
||||
});
|
||||
|
||||
component.onChange((value) => {
|
||||
this.parentDeckID = value === UIAssistant.DECK_ID_NONE ? null : value;
|
||||
});
|
||||
});
|
||||
new Setting(this.contentEl)
|
||||
.setName("Parent deck")
|
||||
.setDesc(idToEdit ? "" : "To make this deck a subdeck, choose a parent deck.")
|
||||
.addDropdown((component) => {
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((button) => {
|
||||
this.createButton = button;
|
||||
button.setDisabled(this.nameOfDeck.trim().length == 0);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
component.addOption(HtmlTag.SELECT.OPTION.Values.NONE, "None");
|
||||
for (const deck of this.data.getAllDecks().filter(d => d.id !== this.idToEdit))
|
||||
component.addOption(deck.id, deck.data.n);
|
||||
component.setValue(this.parentDeckID ?? HtmlTag.SELECT.OPTION.Values.NONE);
|
||||
|
||||
// onOpen(): void {
|
||||
// super.onOpen();
|
||||
// }
|
||||
component.onChange((value) => {
|
||||
this.parentDeckID = HtmlTag.SELECT.OPTION.isNone(value) ? null : value;
|
||||
});
|
||||
});
|
||||
|
||||
onClose(): void {
|
||||
super.onClose();
|
||||
new Setting(this.contentEl)
|
||||
.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) {
|
||||
const result = this.result;
|
||||
setTimeout(() => this.onSubmit(result), 1);
|
||||
}
|
||||
}
|
||||
public override onClose(): void {
|
||||
super.onClose();
|
||||
|
||||
private async submit() {
|
||||
const cb = (editor: DeckEditor) => {
|
||||
editor.setName(this.nameOfDeck);
|
||||
editor.setParent(this.parentDeckID);
|
||||
return true;
|
||||
};
|
||||
if (this.onSubmit !== undefined && this.result !== undefined) {
|
||||
const result = this.result;
|
||||
const onSubmit = this.onSubmit;
|
||||
setTimeout(() => onSubmit(result), 1);
|
||||
}
|
||||
}
|
||||
|
||||
let deckID: DeckID;
|
||||
if (this.idToEdit) {
|
||||
deckID = this.idToEdit;
|
||||
this.data.editDeck(this.idToEdit, cb);
|
||||
}
|
||||
else {
|
||||
deckID = this.data.createDeck(cb).id;
|
||||
}
|
||||
await this.data.save();
|
||||
private async submit() {
|
||||
const cb = (editor: DeckEditor) => {
|
||||
editor.setName(this.nameOfDeck);
|
||||
editor.setParent(this.parentDeckID);
|
||||
return true;
|
||||
};
|
||||
|
||||
const deck = this.data.getDeck(deckID, true);
|
||||
this.result = { id: deckID, data: deck! };
|
||||
}
|
||||
let deckID: DeckID;
|
||||
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.");
|
||||
|
||||
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
||||
entries.forEach(([date, value], index) => {
|
||||
entries.forEach(([date, value], _index) => {
|
||||
let timeString: string;
|
||||
const hours = date.getHours();
|
||||
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.");
|
||||
|
||||
new TableSectionCreator(el.controlEl.createEl("table")).addBody(rowCreator => {
|
||||
entries.forEach(([threshold, value], index) => {
|
||||
entries.forEach(([threshold, value], _index) => {
|
||||
rowCreator.add((col) => {
|
||||
col.add({ text: `Below ${threshold * 100}%:` });
|
||||
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 { 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> {
|
||||
|
||||
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(
|
||||
app: App,
|
||||
private readonly data: DataStore,
|
||||
decks?: DeckIDDataTuple[],
|
||||
private readonly onChoose?: (deck: DeckIDDataTuple, evt: MouseEvent | KeyboardEvent) => void) {
|
||||
onChoose?: OnChooseCallback) {
|
||||
super(app);
|
||||
|
||||
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[]> {
|
||||
|
|
@ -23,13 +45,13 @@ export class SelectDeckModal extends SuggestModal<DeckIDDataTuple> {
|
|||
}
|
||||
|
||||
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('small', { text: value.data.p.length > 0 ? this.descendants(value) : "" });
|
||||
}
|
||||
|
||||
onChooseSuggestion(item: DeckIDDataTuple, evt: MouseEvent | KeyboardEvent): void {
|
||||
this.onChoose?.(item, evt);
|
||||
this.onChoose?.(isAllDecks(item) ? null : item, evt);
|
||||
}
|
||||
|
||||
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;
|
||||
Env.assert(match, "Expected to find the element.")
|
||||
Env.assert(match !== null, "Expected to find the element.")
|
||||
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.
|
||||
*/
|
||||
private currentlyPlaying: { el: HTMLAudioElement, controller: TimeLoopController } | null;
|
||||
private currentlyPlaying: { el: HTMLAudioElement, controller: TimeLoopController } | null = null;
|
||||
|
||||
/**
|
||||
* @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`);
|
||||
|
||||
const getSource = () => {
|
||||
|
|
@ -113,7 +113,7 @@ export class AudioProcessor extends ContentRendererProcessor implements ContentR
|
|||
return "current";
|
||||
|
||||
case HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.Values.INITIAL:
|
||||
default:
|
||||
case null:
|
||||
return "init";
|
||||
}
|
||||
};
|
||||
|
|
@ -465,7 +465,7 @@ class TimeLoopController extends Component {
|
|||
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime}.`);
|
||||
this.seekTo(player, this.info.startTime);
|
||||
break;
|
||||
default:
|
||||
case "current":
|
||||
Env.log.proc(`\t\tRetaining playback position`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { RecycleComponent } from "utils/obs/RecycleComponent";
|
|||
import { PluginSettings } from "Settings";
|
||||
import { Async } from "utils/ts";
|
||||
|
||||
export function createRenderConfig(settings: PluginSettings): ContentProcessorConfig {
|
||||
export function createRenderConfig(_settings: PluginSettings): ContentProcessorConfig {
|
||||
return {
|
||||
media: {
|
||||
preventMultiplePlayback: true,
|
||||
|
|
@ -45,7 +45,7 @@ export class ContentRenderer extends RecycleComponent {
|
|||
this.config = config;
|
||||
}
|
||||
|
||||
protected onRecycled(component: Component): void {
|
||||
protected override onRecycled(component: Component): void {
|
||||
Env.log.d("ContentRenderer:onRecycled");
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
protected onRecycling(): void {
|
||||
protected override onRecycling(): void {
|
||||
Env.log.d("ContentRenderer:onRecycling");
|
||||
this.defaultProcessors = [];
|
||||
this.customProcessors = [];
|
||||
|
|
@ -112,8 +112,7 @@ export class ContentRenderer extends RecycleComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
if (processors)
|
||||
processors.forEach(p => this.recycleComponent.addChild(p));
|
||||
processors.forEach(p => this.recycleComponent.addChild(p));
|
||||
|
||||
const param = {
|
||||
app: this.app,
|
||||
|
|
@ -151,7 +150,6 @@ export class ContentRenderer extends RecycleComponent {
|
|||
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 { App, Component } from "obsidian";
|
||||
import { getDoc } from "utils/obs/dom";
|
||||
import { Doc } from "utils/dom/dom";
|
||||
|
||||
export type MediaContentProcessorConfig = {
|
||||
preventMultiplePlayback: boolean;
|
||||
|
|
@ -74,7 +74,7 @@ export class ContentRendererPostProcessorAssistant {
|
|||
}
|
||||
|
||||
public get doc() {
|
||||
return getDoc(this.param.el);
|
||||
return Doc.get(this.param.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));
|
||||
}
|
||||
|
||||
private handleVideo(param: PostProcessorParameter, el: HTMLVideoElement) {
|
||||
private handleVideo(_param: PostProcessorParameter, el: HTMLVideoElement) {
|
||||
this.setDefaults(el);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/declarations/DeclarationRenderable";
|
||||
import { AlternateHeadingsDeclarable, AlternateHeadingsAssistant } from "declarations/commands/AlternateHeadings";
|
||||
import { AlternateHeadingsAssistant, AlternateHeadingsDeclarable } from "#/declarations/commands/AlternateHeadings";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||
|
||||
const Assistant = AlternateHeadingsAssistant;
|
||||
|
||||
export class AlternateHeadingsRenderer
|
||||
extends DeclarationRenderer<AlternateHeadingsDeclarable>
|
||||
implements DeclarationRenderable {
|
||||
|
||||
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||
|
||||
public render(r: DeclarationRenderAssistant) {
|
||||
|
||||
if (!AlternateHeadingsAssistant.conforms(this.declarable) || !AlternateHeadingsAssistant.isValid(this.declarable)) {
|
||||
if (!Assistant.isValid(this.declarable)) {
|
||||
r.setError();
|
||||
r.setTitle("Invalid alternate headings command");
|
||||
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 { Declarable } from "declarations/Declaration";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant } from "#/renderings/declarations/DeclarationRenderable";
|
||||
import { YamlObject } from "#/declarations/DeclarationCodec";
|
||||
|
||||
|
||||
export class DeclarationErrorRenderer
|
||||
extends DeclarationRenderer<Declarable>
|
||||
implements DeclarationRenderable {
|
||||
|
||||
public method?: (r: DeclarationRenderAssistant, errorMessage?: string) => void;
|
||||
public errorMessage?: string;
|
||||
|
||||
private declaration: YamlObject;
|
||||
|
||||
constructor(declaration: YamlObject) {
|
||||
this.declaration = declaration;
|
||||
}
|
||||
|
||||
public render(r: DeclarationRenderAssistant) {
|
||||
r.setError();
|
||||
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.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.addParagraph("Please check the entered values.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { CardDeclarationAssistant } from "declarations/CardDeclaration";
|
||||
import { CommandDeclarationAssistant } from "declarations/CommandDeclaration";
|
||||
import { Declaration } from "declarations/Declaration";
|
||||
import { Env } from "env";
|
||||
import { CommandableAssistant } from "#/declarations/Commandable";
|
||||
import { DeclarableAssistant } from "#/declarations/Declarable";
|
||||
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
|
||||
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 { 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 {
|
||||
|
||||
private source: string;
|
||||
private dataProvider: DataProvider;
|
||||
|
||||
private titleContainer!: HTMLDivElement;
|
||||
private titleEl!: HTMLDivElement;
|
||||
private contentContainerEl!: HTMLDivElement;
|
||||
|
||||
public constructor(containerEl: HTMLElement, source: string, dataProvider: DataProvider) {
|
||||
super(containerEl);
|
||||
|
||||
|
|
@ -20,25 +24,31 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
|
|||
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 {
|
||||
Env.log.d("DeclarationRenderChild: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.
|
||||
*/
|
||||
public render(onDomEvent: DeclarationChangedEvent) {
|
||||
|
||||
this.initRender();
|
||||
|
||||
const r = new DeclarationRenderAssistant(
|
||||
this.containerEl,
|
||||
this.contentContainerEl,
|
||||
|
|
@ -49,50 +59,28 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
|
|||
onDomEvent
|
||||
);
|
||||
|
||||
const declaration = Declaration.tryParseYaml(this.source, error => this.renderYamlError(r, error.message));
|
||||
if (declaration === null)
|
||||
const declaration = DeclarationCodec.tryFromYaml(this.source, error => this.renderYamlError(r, error.message));
|
||||
if (declaration === null || !DeclarableAssistant.is(declaration))
|
||||
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 (CommandDeclarationAssistant.isHeadingAndDelimiter(declaration))
|
||||
declarationRenderer = new HeadingAndDelimiterRenderer(declaration);
|
||||
else if (CommandDeclarationAssistant.isAlternateHeadings(declaration))
|
||||
declarationRenderer = new AlternateHeadingsRenderer(declaration);
|
||||
else if (CommandDeclarationAssistant.isHeadingIsFront(declaration))
|
||||
declarationRenderer = new HeadingIsFrontRenderer(declaration);
|
||||
if (CommandableAssistant.is(declaration)) {
|
||||
errorRenderer.method = DeclarationErrorRenderer.unknownCommandName;
|
||||
errorRenderer.errorMessage = `Name: ${declaration.name}`;
|
||||
} else {
|
||||
errorRenderer.method = DeclarationErrorRenderer.invalidCardDeclaration;
|
||||
}
|
||||
|
||||
if (declarationRenderer instanceof DeclarationErrorRenderer) {
|
||||
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 = errorRenderer;
|
||||
}
|
||||
|
||||
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) {
|
||||
r.setError();
|
||||
r.setTitle("Invalid format entered");
|
||||
|
|
|
|||
|
|
@ -1,35 +1,63 @@
|
|||
import { DeckIDDataTuple } from "data/DataStore";
|
||||
import { DeckableDeclarable, Declarable } from "declarations/Declaration";
|
||||
import { DeckData, DeckIDDataTuple } from "#/data/DataStore";
|
||||
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 { UIAssistant } from "ui/UIAssistant";
|
||||
|
||||
export type DeclarationChangedType = "deckAdded" | "deckChanged";
|
||||
export type DeclarationChangedEvent = (declaration: DeckableDeclarable, type: DeclarationChangedType, selectEl: HTMLSelectElement) => void;
|
||||
|
||||
export interface DataProvider {
|
||||
getAllDecks: () => DeckIDDataTuple[];
|
||||
getDeck: (id: DeckID) => DeckData | null;
|
||||
};
|
||||
|
||||
export interface DeclarationRenderable {
|
||||
render(r: DeclarationRenderAssistant): void;
|
||||
}
|
||||
|
||||
export interface FactoryRegistryEntry {
|
||||
names: readonly string[];
|
||||
create: (d: unknown) => DeclarationRenderable | null;
|
||||
}
|
||||
|
||||
export abstract class DeclarationRenderer<T extends Declarable> {
|
||||
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 {
|
||||
|
||||
private containerEl: HTMLElement;
|
||||
protected contentContainerEl: HTMLDivElement;
|
||||
private titleContainer: HTMLDivElement;
|
||||
private titleEl: HTMLDivElement;
|
||||
private component: Component;
|
||||
private dataProvider: DataProvider;
|
||||
protected onDomEvent: DeclarationChangedEvent
|
||||
private readonly containerEl: HTMLElement;
|
||||
private readonly contentContainerEl: HTMLDivElement;
|
||||
private readonly titleContainer: HTMLDivElement;
|
||||
private readonly titleEl: HTMLDivElement;
|
||||
private readonly component: Component;
|
||||
private readonly dataProvider: DataProvider;
|
||||
private readonly onDomEvent: DeclarationChangedEvent;
|
||||
|
||||
public constructor(
|
||||
containerEl: HTMLElement,
|
||||
|
|
@ -81,15 +109,19 @@ export class DeclarationRenderAssistant {
|
|||
});
|
||||
}
|
||||
|
||||
public createTable() {
|
||||
return TableCreator.create(this.contentContainerEl);
|
||||
}
|
||||
|
||||
public createDeckRow(body: HTMLTableSectionElement, declaration: DeckableDeclarable) {
|
||||
const rowDeck = body.createEl("tr");
|
||||
rowDeck.createEl("td", { text: "Deck" });
|
||||
const tdDropdown = rowDeck.createEl("td", { cls: "select-deck-cell" });
|
||||
|
||||
const deckSelectEl = tdDropdown.createEl("select", { cls: "dropdown" }, (el) => {
|
||||
el.createEl("option", {
|
||||
const deckSelectEl = tdDropdown.createEl(HtmlTag.SELECT.NAME, { cls: "dropdown" }, (el) => {
|
||||
el.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||
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();
|
||||
if (decks.length > 0) {
|
||||
decks.forEach(deck => {
|
||||
deckSelectEl.createEl("option", {
|
||||
deckSelectEl.createEl(HtmlTag.SELECT.OPTION.NAME, {
|
||||
text: deck.data.n,
|
||||
value: deck.id,
|
||||
}, (el) => {
|
||||
el.selected = declaration?.deckID === deck.id;
|
||||
el.selected = declaration.deckID === deck.id;
|
||||
});
|
||||
});
|
||||
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
|
||||
extends DeclarationRenderer<HeadingAndDelimiterDeclarable>
|
||||
implements DeclarationRenderable {
|
||||
|
||||
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||
|
||||
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.setTitle("Invalid alternate headings command");
|
||||
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.`
|
||||
}),
|
||||
el.createEl("li", undefined, (el) => {
|
||||
if (this.declarable.delimiter === "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.");
|
||||
switch (this.declarable.delimiter) {
|
||||
case UNARY_UNION_SUPPRESS:
|
||||
break;
|
||||
|
||||
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) => {
|
||||
el.createEl("td", { text: "Side delimiter" });
|
||||
el.createEl("td", { text: "Page delimiter" });
|
||||
el.createEl("td", { text: "The first horizontal rule" });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "declarations/commands/HeadingIsFront";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/declarations/DeclarationRenderable";
|
||||
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "#/declarations/commands/HeadingIsFront";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "#/renderings/declarations/DeclarationRenderable";
|
||||
|
||||
const Assistant = HeadingIsFrontAssistant;
|
||||
|
||||
export class HeadingIsFrontRenderer
|
||||
extends DeclarationRenderer<HeadingIsFrontDeclarable>
|
||||
implements DeclarationRenderable {
|
||||
|
||||
public static override canRender = (value: unknown) => Assistant.is(value);
|
||||
|
||||
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.setTitle("Invalid heading is front command");
|
||||
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 { 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) {
|
||||
Env.assert(config);
|
||||
|
||||
const params = generatorParameters({
|
||||
request_retention: default_request_retention,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export class SettingTab extends PluginSettingTab {
|
|||
.addText((component) => {
|
||||
component.setValue(settings.uiPrefix);
|
||||
component.onChange(async (value) => {
|
||||
settings.uiPrefix = value;
|
||||
settings.uiPrefix = value.trim();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import { DeckIDDataTuple } from "data/DataStore";
|
||||
import { DeckID } from "data/FullID";
|
||||
import { App, MarkdownView, Menu, MenuItem, Notice } from "obsidian";
|
||||
import { SettingsManager } from "Settings";
|
||||
import { Icon, PLUGIN_NAME } from "ui/constants";
|
||||
import { Icon } from "ui/constants";
|
||||
import { Str } from "utils/ts";
|
||||
|
||||
|
||||
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;
|
||||
|
||||
constructor(settingsManager: SettingsManager) {
|
||||
|
|
@ -21,10 +14,7 @@ export class UIAssistant {
|
|||
|
||||
public contextulize(title: string) {
|
||||
const contextPrefix = this.settingsManager.settings.uiPrefix;
|
||||
if (contextPrefix !== undefined)
|
||||
return contextPrefix ? `${contextPrefix}: ${title}` : title;
|
||||
else
|
||||
return `${PLUGIN_NAME}: ${title}`;
|
||||
return Str.isNonEmpty(contextPrefix) ? `${contextPrefix}: ${title}` : title;
|
||||
}
|
||||
|
||||
public addMenuItem(menu: Menu, title: string, options?: {
|
||||
|
|
@ -33,8 +23,8 @@ export class UIAssistant {
|
|||
icon?: string,
|
||||
prefix?: boolean,
|
||||
isLabel?: boolean,
|
||||
onClick?: (evt: MouseEvent | KeyboardEvent) => any
|
||||
callback?: (item: MenuItem) => any,
|
||||
onClick?: (evt: MouseEvent | KeyboardEvent) => void,
|
||||
callback?: (item: MenuItem) => void,
|
||||
}): Menu {
|
||||
const {
|
||||
callback,
|
||||
|
|
@ -42,7 +32,7 @@ export class UIAssistant {
|
|||
|
||||
menu.addItem(item => {
|
||||
this.configureMenuItem(item, title, options);
|
||||
callback?.(item);
|
||||
callback?.(item);
|
||||
});
|
||||
|
||||
return menu;
|
||||
|
|
@ -54,7 +44,7 @@ export class UIAssistant {
|
|||
icon?: string,
|
||||
prefix?: boolean,
|
||||
isLabel?: boolean,
|
||||
onClick?: (evt: MouseEvent | KeyboardEvent) => any
|
||||
onClick?: (evt: MouseEvent | KeyboardEvent) => void,
|
||||
}): MenuItem {
|
||||
const {
|
||||
section,
|
||||
|
|
@ -103,21 +93,11 @@ export class UIAssistant {
|
|||
return notice;
|
||||
}
|
||||
|
||||
public static allDecksOptionItem(): DeckIDDataTuple {
|
||||
return {
|
||||
id: UIAssistant.DECK_ID_NONE,
|
||||
data: {
|
||||
n: "All Decks",
|
||||
p: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static isInInLivePreview(app: App) {
|
||||
const markdownView = app.workspace.getActiveViewOfType(MarkdownView)
|
||||
if (!markdownView)
|
||||
return false;
|
||||
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";
|
||||
// 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 { Str } from "utils/ts";
|
||||
// @ts-expect-error
|
||||
|
|
@ -9,16 +10,20 @@ const TIME_FORMAT = "LT";
|
|||
const DATE_FORMAT = "MMM D, LT";
|
||||
|
||||
export const DateTime = {
|
||||
// @ts-expect-error
|
||||
toTimeString: (date: Date) => moment(date).format(TIME_FORMAT),
|
||||
// @ts-expect-error
|
||||
toString: (date: Date) => moment(date).format(DATE_FORMAT),
|
||||
|
||||
dateStringFromIso: (iso8601: string): string => {
|
||||
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
||||
// @ts-expect-error
|
||||
return moment(iso8601).format(DATE_FORMAT);
|
||||
},
|
||||
|
||||
dateFromIso: (iso8601: string): Date => {
|
||||
Env.assert(!Str.is(iso8601 as unknown), "Expected an ISO string.");
|
||||
// @ts-expect-error
|
||||
return moment(iso8601).toDate();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { Str } from "#/utils/ts";
|
||||
|
||||
export const HtmlTag = {
|
||||
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;
|
||||
|
||||
export const HtmlAttribute = {
|
||||
|
|
@ -23,7 +37,7 @@ export const HtmlAttribute = {
|
|||
Controls: {
|
||||
NAME: "controls",
|
||||
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. */
|
||||
|
|
@ -39,7 +53,7 @@ export const HtmlAttribute = {
|
|||
Loop: {
|
||||
NAME: "loop",
|
||||
Values: {
|
||||
DISPLAY: ""
|
||||
DISPLAY: Str.EMPTY
|
||||
}
|
||||
},
|
||||
// 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 {
|
||||
public readonly tableEl: HTMLTableElement;
|
||||
|
||||
|
|
@ -5,22 +11,22 @@ export class TableSectionCreator {
|
|||
this.tableEl = tableEl;
|
||||
}
|
||||
|
||||
public setHeader(cb: (rowCreator: TableRowCreator) => void) {
|
||||
public setHeader(cb?: (rowCreator: TableRowCreator) => void): TableRowCreator {
|
||||
return this.create("thead", cb);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
public setFooter(cb: (rowCreator: TableRowCreator) => void) {
|
||||
public setFooter(cb?: (rowCreator: TableRowCreator) => void): TableRowCreator {
|
||||
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");
|
||||
cb(r);
|
||||
cb?.(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 { UNARY_UNION_SUPPRESS } from "#/utils/ts";
|
||||
|
||||
interface FileParserErrorOptions extends ErrorOptions {
|
||||
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} */
|
||||
export type SectionRange = {
|
||||
/** If `null`, starts at the beginning.*/
|
||||
|
|
@ -36,7 +42,7 @@ export type CodeBlockInfo = {
|
|||
/** May be an empty string if language is not specified. */
|
||||
language: string;
|
||||
/** The location of the content relative to the first tick of the block. */
|
||||
location: { start: number, end: number};
|
||||
location: OffsetRange;
|
||||
content: string;
|
||||
}
|
||||
|
||||
|
|
@ -63,10 +69,13 @@ const FrontmatterSection: ExternalSectionCache = {
|
|||
* Sections of the file that are not part of the actual Markdown.
|
||||
*/
|
||||
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 {
|
||||
|
||||
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}.
|
||||
* @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);
|
||||
}
|
||||
|
||||
public override removeChild<T extends Component>(component: T): T {
|
||||
Env.assert(false);
|
||||
public override removeChild<T extends Component>(component: T): T {
|
||||
Env.assert(false);
|
||||
return super.removeChild(component);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link recycleComponent} was loaded.
|
||||
* 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. */
|
||||
protected onRecycling(): void {};
|
||||
protected onRecycling(): void { };
|
||||
|
||||
protected get recycleComponent() {
|
||||
return this.internalRecycleComponent!;
|
||||
|
|
@ -46,15 +46,12 @@ export abstract class RecycleComponent extends Component {
|
|||
|
||||
private create(): void {
|
||||
this.internalRecycleComponent = new Component();
|
||||
|
||||
if (this.internalRecycleComponent) {
|
||||
this.internalRecycleComponent.load();
|
||||
this.onRecycled(this.internalRecycleComponent);
|
||||
}
|
||||
this.internalRecycleComponent.load();
|
||||
this.onRecycled(this.internalRecycleComponent);
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
if (this.internalRecycleComponent) {
|
||||
if (this.internalRecycleComponent !== null) {
|
||||
this.onRecycling();
|
||||
// Clears its internal list of children and deregisters all its resources. The component is disposed and should no longer be used.
|
||||
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 { 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 {
|
||||
|
||||
/** `<div class="workspace-leaf-content …>` */
|
||||
private workspaceLeafContentEl: HTMLElement | undefined;
|
||||
private markdownViewRootEl: HTMLDivElement | undefined;
|
||||
private previewView: HTMLDivElement | undefined;
|
||||
private previewSizerEl: HTMLDivElement | undefined;
|
||||
|
|
@ -13,6 +17,9 @@ export class ViewAssistant {
|
|||
public init(view: ItemView) {
|
||||
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.previewView = readerViewEl.createDiv({ cls: Arr.toMutable(CssClass.MarkdownView.PREVIEW) }, (previewViewEl) => {
|
||||
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. */
|
||||
public deinit() {
|
||||
|
||||
if (this.workspaceLeafContentEl !== undefined) {
|
||||
El.removeAttribute(this.workspaceLeafContentEl, HtmlAttribute.Data.MODE);
|
||||
this.workspaceLeafContentEl = undefined;
|
||||
}
|
||||
|
||||
if (this.markdownViewRootEl) {
|
||||
this.markdownViewRootEl.empty();
|
||||
this.markdownViewRootEl = undefined;
|
||||
|
|
@ -33,7 +50,7 @@ export class ViewAssistant {
|
|||
}
|
||||
|
||||
public empty() {
|
||||
if (this.previewSizerEl) {
|
||||
if (this.previewSizerEl !== undefined) {
|
||||
const children = this.previewSizerEl.children;
|
||||
for (let i = children.length - 1; i >= 2; 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. */
|
||||
public get containerEl() {
|
||||
if (this.markdownViewRootEl === undefined)
|
||||
throw new Error("Not initialized.");
|
||||
this.throwIfNotInitialized(this.markdownViewRootEl);
|
||||
return this.markdownViewRootEl;
|
||||
}
|
||||
|
||||
/** @throws `Error` if {@link init} has not been called. */
|
||||
public get contentEl() {
|
||||
if (this.previewSizerEl === undefined)
|
||||
throw new Error("Not initialized.");
|
||||
this.throwIfNotInitialized(this.previewSizerEl);
|
||||
return this.previewSizerEl;
|
||||
}
|
||||
|
||||
/** @throws `Error` if {@link init} has not been called. */
|
||||
public get scrollContainer() {
|
||||
this.throwIfNotInitialized(this.previewView);
|
||||
return this.previewView;
|
||||
}
|
||||
|
||||
public adjustAvailableVerticalScrolling() {
|
||||
if (!this.scrollContainer || !this.previewSizerEl)
|
||||
return;
|
||||
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const paddingBottom = Math.floor(containerHeight * 0.5);
|
||||
const minHeight = Math.floor(containerHeight * 0.53);
|
||||
|
||||
this.previewSizerEl.setCssStyles({
|
||||
this.contentEl.setCssStyles({
|
||||
"paddingBottom": `${paddingBottom}px`,
|
||||
"minHeight": `${minHeight}px`,
|
||||
});
|
||||
|
|
@ -78,4 +99,8 @@ export class ViewAssistant {
|
|||
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 = {
|
||||
|
||||
wrapperClassForEl: <K extends keyof HTMLElementTagNameMap>(tag: K) => {
|
||||
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: {
|
||||
READING: "markdown-reading-view",
|
||||
|
||||
|
|
|
|||
|
|
@ -84,17 +84,3 @@ export function createElWrapperHtml<K extends keyof HTMLElementTagNameMap>(tag:
|
|||
else
|
||||
return `<div class="${CssClass.wrapperClassForEl(tag)}">${html}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use `element.win` and `element.doc` to get the window/document that your Dom element is located in.
|
||||
*
|
||||
* `activeWindow`/`Document` refers to the current focused window which might not be the same one your element is in.
|
||||
*/
|
||||
export function getDoc(el: HTMLElement) {
|
||||
return el.doc;
|
||||
}
|
||||
|
||||
/** @see {@link getDoc} */
|
||||
export function getWin(el: HTMLElement) {
|
||||
return el.win;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Env } from "env";
|
||||
import { App, KeymapEventListener, Scope } from "obsidian";
|
||||
import { Env } from "#/env";
|
||||
import { Bln } from "#/utils/ts";
|
||||
import { App, KeymapEventListener, Scope, Vault } from "obsidian";
|
||||
|
||||
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;
|
||||
|
|
|
|||
152
src/utils/ts.ts
152
src/utils/ts.ts
|
|
@ -2,11 +2,20 @@ import { Env } from "env";
|
|||
import { UnsignedInteger } from "types";
|
||||
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 = {
|
||||
firstOrNull: <T>(a: Array<T>): T | null => a.first() ?? null,
|
||||
nonEmpty: <T>(v: T[] | unknown): v is Array<T> => Array.isArray(v) && v.length > 0,
|
||||
toMutable: <T>(a: readonly T[]): T[] => [...a],
|
||||
} as const;
|
||||
clear: <T>(a: T[]): void => { a.length = 0; },
|
||||
};
|
||||
|
||||
export const Async = {
|
||||
/**
|
||||
|
|
@ -40,22 +49,34 @@ export const Async = {
|
|||
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 = {
|
||||
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const KeyValue = {
|
||||
isNotEmpty: <K, V>(v: Map<K, V> | undefined): v is Map<K, V> => v !== undefined && v.size > 0,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const Num = {
|
||||
|
||||
is: (value: unknown): value is number => {
|
||||
return typeof value === "number";
|
||||
return typeof value === "number" && !Number.isNaN(value);
|
||||
},
|
||||
|
||||
isNaN: (value: unknown) => Number.isNaN(value),
|
||||
|
||||
UInt: {
|
||||
assert: (n: number) => Env.assert(isUnsignedInteger(n)),
|
||||
create: (n: number): UnsignedInteger => {
|
||||
|
|
@ -66,7 +87,11 @@ export const Num = {
|
|||
is: isUnsignedInteger,
|
||||
} 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 = {
|
||||
/**
|
||||
|
|
@ -76,18 +101,85 @@ export const Obj = {
|
|||
* @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`.
|
||||
*/
|
||||
is: (value: unknown): value is object => {
|
||||
return typeof value === "object" && value !== null; // `null` is an object
|
||||
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 = {
|
||||
EMPTY: "",
|
||||
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",
|
||||
|
||||
/**
|
||||
* 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 !== "",
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
/**
|
||||
|
|
@ -102,8 +194,50 @@ export const Str = {
|
|||
toIsoStringOrNull: (date: Date | undefined) => {
|
||||
return date !== undefined ? date.toISOString() : null
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
function isUnsignedInteger(value: unknown): value is UnsignedInteger {
|
||||
return Num.is(value) && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { Env } from "env";
|
||||
import { CssClass } from "#/constants";
|
||||
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 { 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 = {
|
||||
data?: DataStore;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -21,10 +25,21 @@ export interface BaseViewEphemeralState {
|
|||
[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 {
|
||||
|
||||
protected static withDefaultViewState<T extends BaseViewState>(state: T): T {
|
||||
return {
|
||||
...state,
|
||||
forceUpdate: true
|
||||
};
|
||||
}
|
||||
|
||||
protected readonly settingsManager: SettingsManager;
|
||||
protected readonly contentRenderer: ContentRenderer;
|
||||
protected readonly interactionAssistant: InteractionAssistant;
|
||||
|
||||
private readonly options?: BaseViewOptionalParameters;
|
||||
private readonly viewAssistant: ViewAssistant;
|
||||
|
|
@ -38,6 +53,7 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
|||
this.options = options;
|
||||
|
||||
this.viewAssistant = new ViewAssistant();
|
||||
this.interactionAssistant = new InteractionAssistant(this.app, this, this.viewAssistant);
|
||||
this.contentRenderer = new ContentRenderer(this.app, createRenderConfig(settingsManager.settings));
|
||||
this.addChild(this.contentRenderer);
|
||||
}
|
||||
|
|
@ -67,8 +83,10 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
|||
}
|
||||
|
||||
this.contentEl.empty();
|
||||
this.containerEl.addClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
||||
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> {
|
||||
|
|
@ -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.domFacade = null;
|
||||
|
||||
El.Cls.remove(this.viewAssistant.workspaceLeafEl, CssClass.View.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
||||
this.interactionAssistant.deinit();
|
||||
this.viewAssistant.deinit();
|
||||
this.containerEl.removeClass(CssClass.WORKSPACE_LEAF_CONTENT_MODIFIER);
|
||||
}
|
||||
|
||||
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);
|
||||
await super.setState(state, result);
|
||||
|
||||
if (!this.didSetState) {
|
||||
this.onSetState(state as State, result);
|
||||
this.didSetState = true;
|
||||
const setState = state as State | null | undefined;
|
||||
Env.assert(setState !== undefined && setState !== null);
|
||||
if (setState === undefined || setState === null)
|
||||
return;
|
||||
|
||||
//this.contentRenderer.recycle();
|
||||
Env.log.view("BaseView:setState: refreshing view because state was set");
|
||||
const proceed = async () => {
|
||||
this.onSetState(setState, result);
|
||||
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. */
|
||||
|
|
@ -182,22 +210,24 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
|||
protected abstract onSetState(state: State, result: ViewStateResult): void;
|
||||
/** Supply the state to the system. */
|
||||
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 abstract onRender(): Promise<void>;
|
||||
|
||||
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void { };
|
||||
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void { };
|
||||
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void { }; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
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 } {
|
||||
return this.viewAssistant.scrollContainer
|
||||
? { top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft }
|
||||
: { top: 0, left: 0 };
|
||||
return {
|
||||
top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
this.viewAssistant.scrollContainer?.scrollTo(options)
|
||||
this.interactionAssistant.nextScrollIsProgrammatic();
|
||||
this.viewAssistant.scrollContainer.scrollTo(options)
|
||||
}
|
||||
|
||||
protected saveState() {
|
||||
|
|
@ -212,7 +242,7 @@ export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
|||
this.domFacade = {
|
||||
contentEl: contentEl,
|
||||
create: new ElementCreator(contentEl),
|
||||
doc: getDoc(contentEl),
|
||||
doc: Doc.get(contentEl),
|
||||
}
|
||||
}
|
||||
return this.domFacade;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export class DecksView extends BaseView<BaseViewState> {
|
|||
|
||||
public static readonly TYPE = "come-through-view-decks";
|
||||
|
||||
public static createViewState(): BaseViewState {
|
||||
return BaseView.withDefaultViewState({});
|
||||
}
|
||||
|
||||
private readonly data: DataStore;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, data: DataStore) {
|
||||
|
|
@ -29,7 +33,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
|||
return "Decks";
|
||||
}
|
||||
|
||||
protected override onSetState(state: BaseViewState, result: ViewStateResult): void {
|
||||
protected override onSetState(_state: BaseViewState, _result: ViewStateResult): void {
|
||||
}
|
||||
|
||||
protected override onGetState(): BaseViewState {
|
||||
|
|
@ -78,7 +82,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
|||
el.createEl("button", { text: "Add" }, (button) => {
|
||||
setIcon(button, "plus");
|
||||
setTooltip(button, "Add new deck");
|
||||
this.contentRenderer.registerDomEvent(button, "click", () => new DeckModal(this.app, this.data, () => { }).open());
|
||||
this.contentRenderer.registerDomEvent(button, "click", () => DeckModal.add(this.app, this.data));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -105,7 +109,7 @@ export class DecksView extends BaseView<BaseViewState> {
|
|||
menu.addItem((item) => {
|
||||
item.setTitle("Edit");
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
|||
|
||||
public static readonly TYPE = "come-through-view-defined-content";
|
||||
public static createViewState(file: TFile): DefinedContentViewState {
|
||||
return {
|
||||
return BaseView.withDefaultViewState({
|
||||
filePath: file.path,
|
||||
};
|
||||
} satisfies DefinedContentViewState);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -25,9 +25,6 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
@ -55,12 +52,14 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
|||
// Note that the data change callback will also be called on file rename.
|
||||
// The "rename" event will be called first.
|
||||
// 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 {
|
||||
super.onunload();
|
||||
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
||||
}
|
||||
|
||||
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.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>.
|
||||
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) });
|
||||
const infoPara = this.dom.create.paraWrapper();
|
||||
|
|
@ -137,19 +138,19 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
|||
|
||||
if (parsedUnit.complete !== null) {
|
||||
const completeUnit = parsedUnit.complete;
|
||||
this.createDefinedContentBlockSection(`${completeUnit.frontID.cardID}: Front`, completeUnit.frontMarkdown);
|
||||
this.createDefinedContentBlockSection(`${completeUnit.backID.cardID}: Back`, completeUnit.backMarkdown);
|
||||
this.createDefinedContentBlockSection(hasRendered, `${completeUnit.frontID.cardID}: Front`, completeUnit.frontMarkdown);
|
||||
this.createDefinedContentBlockSection(hasRendered, `${completeUnit.backID.cardID}: Back`, completeUnit.backMarkdown);
|
||||
numberOfContentDefinitions += 2;
|
||||
}
|
||||
else if (parsedUnit.incomplete !== null) {
|
||||
const incompleteUnit = parsedUnit.incomplete;
|
||||
|
||||
if (incompleteUnit.frontID) {
|
||||
this.createDefinedContentBlockSection(`${incompleteUnit.frontID.cardID}: Front (incomplete)`, incompleteUnit.frontMarkdown);
|
||||
this.createDefinedContentBlockSection(hasRendered, `${incompleteUnit.frontID.cardID}: Front (incomplete)`, incompleteUnit.frontMarkdown);
|
||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||
}
|
||||
if (incompleteUnit.backID) {
|
||||
this.createDefinedContentBlockSection(`${incompleteUnit.backID.cardID}: Back (incomplete)`, incompleteUnit.backMarkdown);
|
||||
this.createDefinedContentBlockSection(hasRendered, `${incompleteUnit.backID.cardID}: Back (incomplete)`, incompleteUnit.backMarkdown);
|
||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -163,13 +164,13 @@ export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
|||
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) => {
|
||||
detailsEl.createEl("summary", { text: summary });
|
||||
const contentDiv = detailsEl.createDiv();
|
||||
this.contentRenderer.registerDomEvent(detailsEl, "toggle", async () => {
|
||||
if (detailsEl.open && !this.hasRendered.has(detailsEl)) {
|
||||
this.hasRendered.set(detailsEl, true);
|
||||
if (detailsEl.open && !hasRendered.has(detailsEl)) {
|
||||
hasRendered.set(detailsEl, true);
|
||||
if (content)
|
||||
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 { DataStore, DeckIDDataTuple } from "data/DataStore";
|
||||
import { DeckID } from "data/FullID";
|
||||
import { Env } from "env";
|
||||
import t from "Localization";
|
||||
import { ReviewItemInfoModal } from "modals/ReviewItemInfoModal";
|
||||
import { ContentParser } from "#/ContentParser";
|
||||
import { DataStore, DeckIDDataTuple } from "#/data/DataStore";
|
||||
import { DeckID } from "#/data/FullID";
|
||||
import { Env } from "#/env";
|
||||
import t from "#/Localization";
|
||||
import { ReviewItemInfoModal } from "#/modals/ReviewItemInfoModal";
|
||||
import { 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 { HeadingProcessor } from "renderings/content/HeadingProcessor";
|
||||
import { ReviewItemInfo } from "scheduling/ReviewItemInfo";
|
||||
import { Scheduler } from "scheduling/Scheduler";
|
||||
import { Rating } from "scheduling/types";
|
||||
import { NextReviewItemOptions, ReviewSortOrder } from "scheduling/types";
|
||||
import { SettingsManager } from "Settings";
|
||||
import { OmitIndexSignature, UnsignedInteger } from "types";
|
||||
import { Icon } from "ui/constants";
|
||||
import { UIAssistant } from "ui/UIAssistant";
|
||||
import { FileParserError } from "utils/obs/FileParser";
|
||||
import { InternalApi } from "utils/obs/internal";
|
||||
import { Num, Obj, Str } from "utils/ts";
|
||||
import { BaseView, BaseViewEphemeralState, BaseViewState } from "views/BaseView";
|
||||
import { ContentUnit } from "views/review/ContentUnit";
|
||||
import { createMetadataEl, createRatingButtons } from "views/review/elements";
|
||||
import { ReviewState } from "views/review/types";
|
||||
|
||||
declare global {
|
||||
interface DOMStringMap {
|
||||
didAdjustButtons?: import("#/utils/ts").BoolStr;
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewViewState extends BaseViewState {
|
||||
deckID: DeckID | null;
|
||||
|
|
@ -51,12 +57,12 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
public static readonly TYPE = "come-through-view-review";
|
||||
public static createViewState(deckID: DeckID | null): ReviewViewState {
|
||||
Env.log.d("ReviewView:createViewState: collection ID:", deckID);
|
||||
return {
|
||||
return BaseView.withDefaultViewState({
|
||||
...DEFAULT_STATE,
|
||||
...{
|
||||
deckID: deckID,
|
||||
},
|
||||
} satisfies ReviewViewState;
|
||||
} satisfies ReviewViewState);
|
||||
}
|
||||
|
||||
private readonly data: DataStore;
|
||||
|
|
@ -177,14 +183,6 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
return this.deck ? `Review: ${this.deck.data.n}` : "Review";
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
Env.log.d("ReviewView:onload");
|
||||
super.onload();
|
||||
|
||||
if (Env.isMobile)
|
||||
this.registerDomEvent(this.contentEl, "dblclick", () => { this.displayNextContentItem(); });
|
||||
}
|
||||
|
||||
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void {
|
||||
Env.log.d("ReviewView:onPaneMenu");
|
||||
super.onPaneMenu(menu, source);
|
||||
|
|
@ -241,17 +239,40 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
Env.log.d("ReviewView:onOpen");
|
||||
await super.onOpen();
|
||||
|
||||
if (Env.isMobile)
|
||||
this.interactionAssistant.registerDoubleClick(this.contentEl, () => this.displayNextContentItem());
|
||||
|
||||
const props = this.forwardBackwardButtonProps();
|
||||
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);
|
||||
this.state = { ...DEFAULT_STATE, ...state };
|
||||
this.deck = state.deckID ? {
|
||||
id: state.deckID,
|
||||
data: this.data.getDeck(state.deckID, true)!
|
||||
} : null;
|
||||
|
||||
const setState = () => {
|
||||
this.state = { ...DEFAULT_STATE, ...state };
|
||||
this.deck = state.deckID ? {
|
||||
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 {
|
||||
|
|
@ -498,8 +519,11 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
this.refreshUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the avalable content from the pager and inserts it into the DOM, replacing the current content.
|
||||
*/
|
||||
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);
|
||||
if (!Num.UInt.is(index))
|
||||
|
|
@ -526,7 +550,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
this.dom.contentEl.appendChild(contentToDisplay);
|
||||
}
|
||||
else {
|
||||
Env.assert(currentContent.parentElement); // Uncaught NotFoundError: Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +563,9 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
private removeAllPages(includeEphemeralState: boolean) {
|
||||
|
|
@ -579,6 +606,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
return this.pager.currentIndex === 1;
|
||||
}
|
||||
|
||||
/** Adjusts the UI components (such as buttons, scrollbar) based on the current state. */
|
||||
private refreshUI() {
|
||||
Env.log.d("ReviewView:refreshUI");
|
||||
|
||||
|
|
@ -626,7 +654,7 @@ export class ReviewView extends BaseView<ReviewViewState> {
|
|||
if (!this.ratingButtonsContainer)
|
||||
return;
|
||||
|
||||
if (this.ratingButtonsContainer.dataset.didAdjustButtons)
|
||||
if (Bln.isTrueStr(this.ratingButtonsContainer.dataset.didAdjustButtons))
|
||||
return;
|
||||
|
||||
// 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;
|
||||
|
||||
let maxWidth = 0;
|
||||
let minContentWidth = Infinity;
|
||||
|
||||
ratingButtons.forEach((btn) => {
|
||||
const width = btn.offsetWidth;
|
||||
if (width > maxWidth)
|
||||
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" }));
|
||||
|
||||
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 { 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");
|
||||
|
||||
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
|
||||
|
|
@ -155,7 +155,7 @@ export function createMetadataEl(
|
|||
}
|
||||
|
||||
row.add((col) => {
|
||||
col.add({ text: "Last review" })
|
||||
col.add({ text: "Last reviewed" })
|
||||
const date = info.lastReview;
|
||||
col.add({
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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) */
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .view-content .rating-buttons > div > button {
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -14,3 +14,17 @@ div.come-through-workspace-leaf-content .markdown-reading-view {
|
|||
div.come-through-workspace-leaf-content :lang(th) {
|
||||
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": {
|
||||
"baseUrl": "./src",
|
||||
// Enable to check TS7 compatibility.
|
||||
"stableTypeOrdering": false,
|
||||
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"*": ["./src/*"],
|
||||
"#/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
//"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2022"
|
||||
]
|
||||
],
|
||||
"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": [
|
||||
"./src/**/*.ts"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
import process from "process";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"0.6.1": "1.8.2",
|
||||
"0.6.0": "1.8.0",
|
||||
"0.5.1": "1.8.0",
|
||||
"0.5.0": "1.8.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue