cmorooney_obsidian-game-sea.../src/main.ts

438 lines
17 KiB
TypeScript

import {
MarkdownView,
Notice,
ProgressBarComponent,
Plugin,
TAbstractFile,
TFolder,
TFile,
Vault,
normalizePath,
} from 'obsidian';
import { GameSearchModal } from '@views/game_search_modal';
import { GameSuggestModal } from '@views/game_suggest_modal';
import { ConfirmRegenModal } from '@views/confirm_regen_modal';
import { CursorJumper } from '@utils/cursor_jumper';
import { RAWGGame, RAWGGameFromSearch } from '@models/rawg_game.model';
import { GameSearchSettingTab, GameSearchPluginSettings, DEFAULT_SETTINGS } from '@settings/settings';
import { replaceVariableSyntax, makeFileName, stringToMap, mapToString } from '@utils/utils';
import { RAWGAPI } from '@src/apis/rawg_games_api';
import { SteamAPI } from '@src/apis/steam_api';
import {
getTemplateContents,
applyTemplateTransformations,
useTemplaterPluginInFile,
executeInlineScriptsTemplates,
} from '@utils/template';
import { syncAchievements, syncSteamWishlist, syncOwnedSteamGames, syncPlaytimes } from '@utils/steamSync';
export type Nullable<T> = T | undefined | null;
export default class GameSearchPlugin extends Plugin {
settings: GameSearchPluginSettings;
rawgApi: RAWGAPI;
steamApi: Nullable<SteamAPI>;
async onload() {
console.info(
`[Game Search][Info] version ${this.manifest.version} (requires obsidian ${this.manifest.minAppVersion})`,
);
await this.loadSettings();
this.rawgApi = new RAWGAPI(this.settings.rawgApiKey);
if (this.settings.syncSteamOnStart) {
this.syncSteam(false);
}
if (this.settings.syncSteamPlaytimeOnStart) {
this.syncSteamPlaytime(false);
}
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('gamepad-2', 'Create new game note', () => this.createNewGameNote(null)); // passing null/undefined for params here will force user to game search
// Perform additional things with the ribbon
ribbonIconEl.addClass('obsidian-game-search-plugin-ribbon-class');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-game-search-modal',
name: 'Create new game note',
callback: () => this.createNewGameNote(undefined), // passing null/undefined for params here will force user to game search
});
this.addCommand({
id: 'open-game-search-modal-to-insert',
name: 'Insert the metadata',
editorCallback: () => this.insertMetadata(),
});
this.addCommand({
id: 'sync steam',
name: 'Sync Steam',
callback: () => this.syncSteam(true),
});
this.addCommand({
id: 'sync steam playtime',
name: 'Sync Steam Playtime',
callback: () => this.syncSteamPlaytime(true),
});
this.addCommand({
id: 'sync steam achievements',
name: 'Sync Steam Achievements',
callback: () => this.syncSteamAchievements(true),
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new GameSearchSettingTab(this.app, this));
}
showNotice(message: unknown) {
try {
new Notice(message?.toString());
} catch {
// eslint-disable
}
}
// open modal for game search
async searchGameMetadata(query?: string): Promise<RAWGGame> {
const searchedGames = await this.openGameSearchModal(query);
return await this.openGameSuggestModal(searchedGames);
}
async getRenderedContents(game: RAWGGame) {
const { templateFile } = this.settings;
const templateContents = await getTemplateContents(this.app, templateFile);
const replacedVariable = replaceVariableSyntax(game, applyTemplateTransformations(templateContents));
return executeInlineScriptsTemplates(game, replacedVariable);
}
async insertMetadata(): Promise<void> {
try {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
console.warn('[Game Search][Insert Metadata] Can not find an active markdown view');
return;
}
const game = await this.searchGameMetadata(markdownView.file.basename);
if (!markdownView.editor) {
console.warn('[Game Search][Insert Metadata] Can not find editor from the active markdown view');
return;
}
const renderedContents = await this.getRenderedContents(game);
markdownView.editor.replaceRange(renderedContents, { line: 0, ch: 0 });
} catch (err) {
console.warn('[Game Search][Insert Metadata][unexepected] ' + err);
this.showNotice(err);
}
}
async regenerateAllGameNotesMetadata(): Promise<void> {
new ConfirmRegenModal(this.app, () => {
const loadingNotice = new Notice('regenerating game metadata', 0);
const progress = new ProgressBarComponent(loadingNotice.noticeEl);
const gamesFolder = this.app.vault.getAbstractFileByPath(normalizePath(this.settings.folder)) as TFolder;
let index = 0;
const fileCount = gamesFolder.children.length;
Vault.recurseChildren(gamesFolder, async (f: TAbstractFile) => {
const file = f as TFile;
if (!!file && file.name.includes('.md')) {
try {
const noteMetadata = await this.parseFileMetadata(f as TFile);
const q: Nullable<string> =
noteMetadata.id ?? noteMetadata.Id ?? noteMetadata.slug ?? noteMetadata.Slug ?? null;
let game: Nullable<RAWGGame> = null;
if (q) {
game = await this.rawgApi.getBySlugOrId(q);
} else {
const games = await this.rawgApi.getByQuery(noteMetadata.name ?? noteMetadata.name ?? file.name);
game = await this.rawgApi.getBySlugOrId(games[0].slug);
}
if (game) {
// get the current contents of the file
// (and the current metadata)
let existingContent = await this.app.vault.read(file);
let existingMetadata: Nullable<Map<string, string>> = undefined;
if (existingContent.indexOf('---') === 0) {
existingMetadata = stringToMap(existingContent.match(/---[\S\s]*?---/)[0]);
}
// re-generate the file contents based on (ostensibly) the changed template
const regeneratedContent = await this.getRenderedContents(game);
// find/capture the regenerated metadata
let regeneratedMetadata: Nullable<Map<string, string>> = existingMetadata;
if (regeneratedContent.indexOf('---') === 0) {
const foundRegeneratedMetadata = regeneratedContent.match(/---[\S\s]*?---/);
if (foundRegeneratedMetadata.length > 0) {
regeneratedMetadata = stringToMap(foundRegeneratedMetadata[0]);
}
}
// if the existing metadata has keys that were injected
// as a part of the users steam sync settings we do want to
// preserve those
if (regeneratedMetadata instanceof Map && existingMetadata instanceof Map) {
const preservePrevious = (key: string) => {
if (existingMetadata.has(key)) {
regeneratedMetadata.set(key, existingMetadata.get(key));
}
};
preservePrevious('steamId');
preservePrevious('steamPlaytimeForever');
preservePrevious('steamPlaytime2Weeks');
if (this.settings.metaDataForWishlistedSteamGames) {
const wishlistMap = stringToMap(this.settings.metaDataForWishlistedSteamGames);
if (wishlistMap instanceof Map) {
for (const [key, value] of this.settings.metaDataForWishlistedSteamGames) {
if (existingMetadata.has(key)) {
regeneratedMetadata.set(key, value);
}
}
}
}
if (this.settings.metaDataForOwnedSteamGames) {
const ownedMap = stringToMap(this.settings.metaDataForOwnedSteamGames);
if (ownedMap instanceof Map) {
for (const [key, value] of stringToMap(this.settings.metaDataForOwnedSteamGames)) {
if (existingMetadata.has(key)) {
regeneratedMetadata.set(key, value);
}
}
}
}
}
// replace the metadata in the existing content with the newly generated metadata
// if there is no metadata in the existing file, just toss the newly generated metadata at the top of the content
// make sure the first instance of `---` is at the start of the file and therefore declaring metadata
// (and not some horizontal rule later in the file)
if (existingContent.indexOf('---') === 0) {
existingContent = existingContent.replace(
/---[\S\s]*?---/,
'---\n' + mapToString(regeneratedMetadata) + '\n---',
);
} else if (regeneratedMetadata) {
existingContent = '---\n' + mapToString(regeneratedMetadata) + '\n---\n' + existingContent;
}
await this.app.vault.modify(file, executeInlineScriptsTemplates(game, existingContent));
const p = ++index / fileCount;
progress.setValue(p * 100);
if (p >= 1) {
loadingNotice.setMessage('game notes regeneration complete');
}
}
} catch (error) {
console.error('[GameSearch][Regen] unexpected error regenerating file ' + file.name);
}
}
});
}).open();
}
async syncSteamAchievements(alertUninitializedApi: boolean): Promise<void> {
// always check to see if steamApi needs to be initialized on sync,
// it's possible the user has entered API credentials at any point in time.
if (this.steamApi === undefined && this.settings.steamApiKey && this.settings.steamUserId) {
console.info('[Game Search][Steam Sync]: initializing steam api');
this.steamApi = new SteamAPI(this.settings.steamApiKey, this.settings.steamUserId);
}
if (this.steamApi !== undefined) {
new Notice('syncing steam achievements');
await syncAchievements(this.app.vault, this.app.fileManager, this.steamApi, this.settings);
new Notice('steam achievements sync complete');
} else if (alertUninitializedApi) {
console.warn('[Game Search][SteamSync]: steam api not initialized');
this.showNotice('Steam Api not initialized. Did you enter your steam API key and user Id in plugin settings?');
}
}
async syncSteamPlaytime(alertUninitializedApi: boolean): Promise<void> {
// always check to see if steamApi needs to be initialized on sync,
// it's possible the user has entered API credentials at any point in time.
if (this.steamApi === undefined && this.settings.steamApiKey && this.settings.steamUserId) {
console.info('[Game Search][Steam Sync]: initializing steam api');
this.steamApi = new SteamAPI(this.settings.steamApiKey, this.settings.steamUserId);
}
if (this.steamApi !== undefined) {
new Notice('syncing steam playtime');
await syncPlaytimes(this.app.vault, this.app.fileManager, this.steamApi, this.settings);
new Notice('steam playtime sync complete');
} else if (alertUninitializedApi) {
console.warn('[Game Search][SteamSync]: steam api not initialized');
this.showNotice('Steam Api not initialized. Did you enter your steam API key and user Id in plugin settings?');
}
}
async syncSteam(alertUninitializedApi: boolean): Promise<void> {
// always check to see if steamApi needs to be initialized on sync,
// it's possible the user has entered API credentials at any point in time.
if (this.steamApi === undefined && this.settings.steamApiKey && this.settings.steamUserId) {
console.info('[Game Search][Steam Sync]: initializing steam api');
this.steamApi = new SteamAPI(this.settings.steamApiKey, this.settings.steamUserId);
}
if (this.steamApi !== undefined) {
const loadingNotice = new Notice('syncing steam games (owned)', 0);
let progress = new ProgressBarComponent(loadingNotice.noticeEl);
await syncOwnedSteamGames(
this.app.vault,
this.settings,
this.app.fileManager,
this.rawgApi,
this.steamApi,
async (params, openAfterCreate, extraData) => await this.createNewGameNote(params, openAfterCreate, extraData),
(percent: number) => progress.setValue((percent * 100) / 2),
);
loadingNotice.setMessage('syncing steam games (wishlist)');
progress = new ProgressBarComponent(loadingNotice.noticeEl);
await syncSteamWishlist(
this.app.vault,
this.settings,
this.app.fileManager,
this.rawgApi,
this.steamApi,
async (params, openAfterCreate, extraData) => await this.createNewGameNote(params, openAfterCreate, extraData),
(percent: number) => progress.setValue(50 + (percent * 100) / 2),
);
loadingNotice.setMessage('steam sync complete');
} else if (alertUninitializedApi) {
console.warn('[Game Search][SteamSync]: steam api not initialized');
this.showNotice('Steam Api not initialized. Did you enter your steam API key and user Id in plugin settings?');
}
}
async parseFileMetadata(file: TFile): Promise<any> {
const fileManager = this.app.fileManager;
return new Promise<any>(accept => {
fileManager.processFrontMatter(file, (data: any) => {
accept(data);
});
});
}
async createNewGameNote(
params: Nullable<{
game: Nullable<RAWGGame>;
steamId: Nullable<number>;
steamPlaytimeForever: number;
steamPlaytime2Weeks: number;
}>,
openAfterCreate = true,
extraData?: Map<string, string>, // key/values for metadata to add to file
): Promise<void> {
try {
const game = params?.game ?? (await this.searchGameMetadata());
// if no steam params passed,
// and settings set to try and match new game notes to steam,
// try and match new game to steam
if (
!params?.steamId &&
this.settings.tryFindSteamGameOnCreate &&
this.steamApi &&
this.settings.steamApiKey &&
this.settings.steamUserId
) {
const maybeMatchedSteamGame = await this.steamApi.tryGetGame(game.name);
if (maybeMatchedSteamGame) {
params = {
game: null,
steamId: maybeMatchedSteamGame.appid,
steamPlaytimeForever: maybeMatchedSteamGame.playtime_forever,
steamPlaytime2Weeks: maybeMatchedSteamGame.playtime_2weeks,
};
}
}
// open file
const activeLeaf = this.app.workspace.getLeaf();
if (!activeLeaf) {
console.warn('[Game Search][Create Game Note] No active leaf');
return;
}
const renderedContents = await this.getRenderedContents(game);
// create new File
const fileName = makeFileName(game, this.settings.fileNameFormat);
const filePath = `${this.settings.folder}/${fileName}`;
const targetFile = await this.app.vault.create(filePath, renderedContents);
// if use Templater plugin
await useTemplaterPluginInFile(this.app, targetFile);
if (params && params.steamId) {
this.app.fileManager.processFrontMatter(targetFile, (data: any) => {
data.steamId = params.steamId;
data.steamPlaytimeForever = params.steamPlaytimeForever;
data.steamPlaytime2Weeks = params.steamPlaytime2Weeks;
if (extraData && extraData instanceof Map) {
for (const [key, value] of extraData) {
data[key] = value;
}
}
return data;
});
}
// open file
if (openAfterCreate) {
await activeLeaf.openFile(targetFile, { state: { mode: 'source' } });
activeLeaf.setEphemeralState({ rename: 'all' });
// cursor focus
await new CursorJumper(this.app).jumpToNextCursorLocation();
}
} catch (err) {
console.warn('[Game Search][Create Game Note][unexpected] ' + err);
if (!err.message.toLowerCase().contains('already exists')) {
this.showNotice(err);
}
}
}
async openGameSearchModal(query = ''): Promise<RAWGGameFromSearch[]> {
return new Promise((resolve, reject) => {
return new GameSearchModal(this, this.rawgApi, query, (error, results) => {
return error ? reject(error) : resolve(results);
}).open();
});
}
async openGameSuggestModal(games: RAWGGameFromSearch[]): Promise<RAWGGame> {
return new Promise((resolve, reject) => {
return new GameSuggestModal(this.app, this.rawgApi, games, (error, selectedGame) => {
return error ? reject(error) : resolve(selectedGame);
}).open();
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// Re-initialize the API, in case the API key was changed
this.rawgApi = new RAWGAPI(this.settings.rawgApiKey);
}
}