This commit is contained in:
Mantano 2025-12-21 09:56:05 +07:00
parent c9f3e55df3
commit 653820a66a
80 changed files with 3177 additions and 1783 deletions

9
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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),
}
};

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

View 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) {
}
}

View file

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

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

View file

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

View file

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

View 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"];
}

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.");

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.");

View 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.");
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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";
}
/**

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { readFileSync, writeFileSync } from "fs";
import process from "process";
const targetVersion = process.env.npm_package_version;

View file

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