fix: address Obsidian plugin reviewer findings

- Move modal code styling to styles.css; use Modal.setTitle() for the heading
- Replace raw <h3> settings headings with Setting().setHeading()
- Use activeDocument instead of document for popout-window compatibility
- Use window.setTimeout instead of bare setTimeout
- Type Last.fm/Trakt API responses and Templater access; remove all `any`
- Drop unnecessary regex escape in sanitizeTitle
- Add tag-triggered release workflow that attaches main.js/manifest.json/styles.css

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sonophage 2026-06-15 15:14:27 -07:00
parent 495f75ebe2
commit 45bd08d5a1
7 changed files with 125 additions and 36 deletions

34
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Build
run: |
npm ci
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" \
--title="$TAG" \
--generate-notes \
main.js manifest.json styles.css

23
main.ts
View file

@ -4,12 +4,12 @@ import { StardustImporterSettingTab } from "./src/settings-tab";
import { TraktClient, DeviceCode } from "./src/trakt";
import { LastfmClient } from "./src/lastfm";
import { detectAlbumListens } from "./src/sessions";
import { NoteWriter, sanitizeTitle } from "./src/notes";
import { NoteWriter, sanitizeTitle, TemplatePayload } from "./src/notes";
export default class StardustImporter extends Plugin {
settings!: StardustImporterSettings;
// Payloads handed to Templater, keyed by note basename (== tp.file.title).
pending: Record<string, any> = {};
pending: Record<string, TemplatePayload> = {};
trakt!: TraktClient;
lastfm!: LastfmClient;
notes!: NoteWriter;
@ -37,8 +37,8 @@ export default class StardustImporter extends Plugin {
onunload(): void {
if (this.startupTimer !== null) window.clearTimeout(this.startupTimer);
document.body.style.removeProperty("--tmdb-api-key");
document.body.style.removeProperty("--steamgriddb-api-key");
activeDocument.body.style.removeProperty("--tmdb-api-key");
activeDocument.body.style.removeProperty("--steamgriddb-api-key");
}
/**
@ -49,12 +49,12 @@ export default class StardustImporter extends Plugin {
*/
applyMediaKeys(): void {
const { enabled, mediaKeys } = this.settings;
document.body.style.setProperty("--tmdb-api-key", enabled.tmdb ? mediaKeys.tmdb : "");
document.body.style.setProperty("--steamgriddb-api-key", enabled.steamgriddb ? mediaKeys.steamgriddb : "");
activeDocument.body.style.setProperty("--tmdb-api-key", enabled.tmdb ? mediaKeys.tmdb : "");
activeDocument.body.style.setProperty("--steamgriddb-api-key", enabled.steamgriddb ? mediaKeys.steamgriddb : "");
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) ?? {};
const data = ((await this.loadData()) as Record<string, unknown> | null) ?? {};
// Migrate the pre-release field name: referencesFolder → mediaFolder.
// Preserves an existing user's configured destination across the rename.
if (data.referencesFolder && !data.mediaFolder) {
@ -234,14 +234,9 @@ class TraktAuthModal extends Modal {
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h3", { text: "Connect Trakt" });
this.setTitle("Connect Trakt");
contentEl.createEl("p", { text: "A browser tab is opening. Enter this code there to authorize:" });
const code = contentEl.createEl("div", { text: this.dc.user_code });
code.style.fontSize = "2em";
code.style.fontWeight = "bold";
code.style.letterSpacing = "0.15em";
code.style.textAlign = "center";
code.style.margin = "0.5em 0";
contentEl.createEl("div", { text: this.dc.user_code, cls: "stardust-trakt-code" });
contentEl.createEl("p", { text: this.dc.verification_url, cls: "mod-muted" });
contentEl.createEl("p", { text: "Waiting for authorization… you can close this once it's done." });
}

View file

@ -10,6 +10,24 @@ export interface Scrobble {
uts: number; // unix seconds
}
// Subsets of the Last.fm JSON responses we read.
interface RawTrack {
"@attr"?: { nowplaying?: string };
date?: { uts?: string };
artist?: { "#text"?: string; name?: string };
album?: { "#text"?: string };
name?: string;
}
interface RecentTracksResponse {
recenttracks?: {
"@attr"?: { totalPages?: string };
track?: RawTrack | RawTrack[];
};
}
interface AlbumInfoResponse {
album?: { tracks?: { track?: unknown | unknown[] } };
}
export class LastfmClient {
private trackCountCache: Record<string, number> = {};
@ -40,13 +58,12 @@ export class LastfmClient {
if (from > 0) params.set("from", String(from));
const res = await requestUrl({ url: `${LFM}?${params.toString()}`, method: "GET", throw: false });
if (res.status !== 200) break;
const rt = (res.json as any)?.recenttracks;
const rt = (res.json as RecentTracksResponse).recenttracks;
if (!rt) break;
const attr = rt["@attr"] || {};
totalPages = parseInt(attr.totalPages || "1") || 1;
totalPages = parseInt(rt["@attr"]?.totalPages || "1") || 1;
const tracks = Array.isArray(rt.track) ? rt.track : rt.track ? [rt.track] : [];
for (const t of tracks) {
if (t["@attr"] && t["@attr"].nowplaying === "true") continue; // no timestamp
if (t["@attr"]?.nowplaying === "true") continue; // no timestamp
const uts = parseInt(t.date?.uts || "0");
if (!uts) continue;
out.push({
@ -77,7 +94,7 @@ export class LastfmClient {
try {
const res = await requestUrl({ url: `${LFM}?${params.toString()}`, method: "GET", throw: false });
if (res.status === 200) {
const tr = (res.json as any)?.album?.tracks?.track;
const tr = (res.json as AlbumInfoResponse).album?.tracks?.track;
count = Array.isArray(tr) ? tr.length : tr ? 1 : 0;
}
} catch (e) {

View file

@ -1,10 +1,25 @@
import { Notice, TFile, normalizePath } from "obsidian";
import type StardustImporter from "../main";
// Payload handed to a Templater template, keyed by note basename. Shapes vary
// per template (movie vs album), so the values stay loosely typed.
export type TemplatePayload = Record<string, unknown>;
// Minimal shape of the Templater plugin we rely on, reached through app.plugins
// (not part of Obsidian's public typings).
interface TemplaterPlugin {
templater?: {
write_template_to_file(template: TFile, file: TFile): Promise<void>;
};
}
interface AppWithPlugins {
plugins: { plugins: Record<string, TemplaterPlugin | undefined> };
}
// Mirror the sanitization the Movie/Album templates apply when renaming.
export function sanitizeTitle(title: string): string {
const colonFixed = title.replace(/:/g, " - ");
return colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
return colonFixed.replace(/[/#%&{}<>*?$!'"@+`|=]/g, "").trim();
}
export class NoteWriter {
@ -18,14 +33,14 @@ export class NoteWriter {
return this.plugin.settings.mediaFolder;
}
private templater(): any {
const t = (this.app as any).plugins.plugins["templater-obsidian"];
return t?.templater ?? null;
private templater(): TemplaterPlugin["templater"] | null {
const plugins = (this.app as unknown as AppWithPlugins).plugins.plugins;
return plugins["templater-obsidian"]?.templater ?? null;
}
// Create a note from a Templater template, handing it the payload (keyed by
// basename == tp.file.title). Returns the file, or null on failure.
async createFromTemplate(templatePath: string, basename: string, payload: any): Promise<TFile | null> {
async createFromTemplate(templatePath: string, basename: string, payload: TemplatePayload): Promise<TFile | null> {
const tmpl = this.app.vault.getAbstractFileByPath(normalizePath(templatePath));
if (!(tmpl instanceof TFile)) {
new Notice(`Stardust Importer: template not found — ${templatePath}`);

View file

@ -13,7 +13,7 @@ export class StardustImporterSettingTab extends PluginSettingTab {
const save = () => this.plugin.saveSettings();
// ---- Trakt (movies) -------------------------------------------------
containerEl.createEl("h3", { text: "Trakt (movies)" });
new Setting(containerEl).setName("Trakt (movies)").setHeading();
new Setting(containerEl)
.setName("Enable Trakt")
@ -68,7 +68,7 @@ export class StardustImporterSettingTab extends PluginSettingTab {
}
// ---- Last.fm (albums) -----------------------------------------------
containerEl.createEl("h3", { text: "Last.fm (albums)" });
new Setting(containerEl).setName("Last.fm (albums)").setHeading();
new Setting(containerEl)
.setName("Enable Last.fm")
@ -152,7 +152,7 @@ export class StardustImporterSettingTab extends PluginSettingTab {
}
// ---- TMDB (artwork & metadata) --------------------------------------
containerEl.createEl("h3", { text: "TMDB (movie/show artwork & metadata)" });
new Setting(containerEl).setName("TMDB (movie/show artwork & metadata)").setHeading();
new Setting(containerEl)
.setName("Enable TMDB")
@ -182,7 +182,7 @@ export class StardustImporterSettingTab extends PluginSettingTab {
}
// ---- SteamGridDB (game artwork) -------------------------------------
containerEl.createEl("h3", { text: "SteamGridDB (game artwork)" });
new Setting(containerEl).setName("SteamGridDB (game artwork)").setHeading();
new Setting(containerEl)
.setName("Enable SteamGridDB")
@ -212,7 +212,7 @@ export class StardustImporterSettingTab extends PluginSettingTab {
}
// ---- General (always shown) -----------------------------------------
containerEl.createEl("h3", { text: "General" });
new Setting(containerEl).setName("General").setHeading();
new Setting(containerEl)
.setName("Media folder")

View file

@ -20,8 +20,27 @@ export interface DeviceCode {
interval: number;
}
// Shape of the OAuth token payload returned by Trakt.
interface TokenResponse {
access_token: string;
refresh_token: string;
created_at: number; // unix seconds
expires_in: number; // seconds
}
// Subset of a Trakt history entry we read.
interface TraktHistoryItem {
id: number;
watched_at?: string;
movie?: {
title?: string;
year?: number | null;
ids?: { tmdb?: number | null; imdb?: string | null };
};
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
return new Promise((r) => window.setTimeout(r, ms));
}
export class TraktClient {
@ -73,7 +92,7 @@ export class TraktClient {
})
});
if (res.status === 200) {
this.saveTokens(res.json);
this.saveTokens(res.json as TokenResponse);
return true;
}
if (res.status === 400) continue; // authorization pending
@ -86,7 +105,7 @@ export class TraktClient {
return false;
}
private saveTokens(t: any): void {
private saveTokens(t: TokenResponse): void {
this.s.accessToken = t.access_token;
this.s.refreshToken = t.refresh_token;
this.s.tokenExpiresAt = (t.created_at + t.expires_in) * 1000;
@ -109,7 +128,7 @@ export class TraktClient {
grant_type: "refresh_token"
})
});
if (res.status === 200) this.saveTokens(res.json);
if (res.status === 200) this.saveTokens(res.json as TokenResponse);
}
// Watched movies, newest first. With startAt (ISO), only newer history.
@ -129,10 +148,10 @@ export class TraktClient {
throw: false
});
if (res.status !== 200) break;
const items = (res.json as any[]) || [];
const items = (res.json as TraktHistoryItem[]) || [];
for (const it of items) {
const m = it.movie || {};
const ids = m.ids || {};
const m = it.movie ?? {};
const ids = m.ids ?? {};
out.push({
historyId: it.id,
watchedAt: String(it.watched_at || "").slice(0, 10),

9
styles.css Normal file
View file

@ -0,0 +1,9 @@
/* Stardust Importer */
.stardust-trakt-code {
font-size: 2em;
font-weight: bold;
letter-spacing: 0.15em;
text-align: center;
margin: 0.5em 0;
}