diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..71a5acc --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/main.ts b/main.ts index b2865ef..8f6f30b 100644 --- a/main.ts +++ b/main.ts @@ -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 = {}; + pending: Record = {}; 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 { - const data = (await this.loadData()) ?? {}; + const data = ((await this.loadData()) as Record | 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." }); } diff --git a/src/lastfm.ts b/src/lastfm.ts index 9d0595a..a93f482 100644 --- a/src/lastfm.ts +++ b/src/lastfm.ts @@ -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 = {}; @@ -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) { diff --git a/src/notes.ts b/src/notes.ts index 9f71eae..36b099e 100644 --- a/src/notes.ts +++ b/src/notes.ts @@ -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; + +// 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; + }; +} +interface AppWithPlugins { + plugins: { plugins: Record }; +} + // 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 { + async createFromTemplate(templatePath: string, basename: string, payload: TemplatePayload): Promise { const tmpl = this.app.vault.getAbstractFileByPath(normalizePath(templatePath)); if (!(tmpl instanceof TFile)) { new Notice(`Stardust Importer: template not found — ${templatePath}`); diff --git a/src/settings-tab.ts b/src/settings-tab.ts index aea704d..fccd8f8 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -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") diff --git a/src/trakt.ts b/src/trakt.ts index e5aad83..71756fa 100644 --- a/src/trakt.ts +++ b/src/trakt.ts @@ -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 { - 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), diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..236a708 --- /dev/null +++ b/styles.css @@ -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; +}