mirror of
https://github.com/sonophage/stardust-importer.git
synced 2026-07-22 07:48:44 +00:00
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:
parent
495f75ebe2
commit
45bd08d5a1
7 changed files with 125 additions and 36 deletions
34
.github/workflows/release.yml
vendored
Normal file
34
.github/workflows/release.yml
vendored
Normal 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
23
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<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." });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
25
src/notes.ts
25
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<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}`);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
33
src/trakt.ts
33
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<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
9
styles.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue