mirror of
https://github.com/rmccorkl/TubeSage.git
synced 2026-07-22 06:45:31 +00:00
chore: stop tracking docs/superpowers workflow docs
The superpowers specs/plans are internal workflow scaffolding, not distributable project docs. Untrack them and add docs/superpowers/ to .gitignore. Files are kept locally. This also clears a GitHub secret-scanning false positive: a spec quoted YouTube's public InnerTube key, which matches the AIzaSy* Google-API-key pattern. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f72baf6e60
commit
986e8e961d
19 changed files with 3 additions and 3911 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -48,6 +48,9 @@ Style2CSS.txt
|
|||
# Chromium user data (contains browsing session data)
|
||||
chromium-user-data/
|
||||
|
||||
# Superpowers workflow docs (specs/plans) — internal scaffolding, not for distribution
|
||||
docs/superpowers/
|
||||
|
||||
# Personal deploy script — references local vault paths; not for distribution
|
||||
scripts/deploy.sh
|
||||
.fallow/
|
||||
|
|
|
|||
|
|
@ -1,495 +0,0 @@
|
|||
# Obsidian Community Portal Review Remediation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Clear the Obsidian Community Portal review warnings for TubeSage so a new release (v1.2.28) passes the automated scan.
|
||||
|
||||
**Architecture:** The review (commit `9338b51`, v1.2.27) produced 8 categories of findings. They split into three workstreams by risk and verifiability: **Phase 1** — mechanical fixes that are concretely planned here and ship in one v1.2.28 release; **Phase 2** — the CSS `!important` / `:has()` refactor, which can only be verified by visually loading the plugin in Obsidian, so it is documented as a procedure, not pre-baked code; **Phase 3** — the `setInterval` finding, already investigated and resolved (it is a disclosure, folded into Phase 1 Task 5).
|
||||
|
||||
**Tech Stack:** TypeScript, esbuild, GitHub Actions, Obsidian plugin API.
|
||||
|
||||
---
|
||||
|
||||
## Investigation findings (resolved before planning)
|
||||
|
||||
- **langsmith ×3 advisories** — already fixed on `main`. `npm ls langsmith` resolves `langsmith@0.6.3` via the `package.json` override `"langsmith": ">=0.6.0"`. 0.6.3 ≥ all three advisory thresholds (0.4.6 / 0.5.18 / 0.5.19). The warning is stale because the review scanned `9338b51` (the 1.2.27 release commit), which predates the override bump. **Cutting v1.2.28 from current `main` clears it — no code change.**
|
||||
- **Build verification mismatch** — a fresh `npm ci && npm run build` from commit `9338b51` produces a `main.js` that is **byte-identical** to the released 1.2.27 asset (1,712,967 bytes, `cmp` confirms IDENTICAL). The build *is* reproducible from the committed lockfile. The portal's mismatch is because `release.yml` uses `npm install` (unpinned) rather than `npm ci` (locked). Fix: switch to `npm ci` — Task 4.
|
||||
- **setInterval + network** — two `setInterval` calls exist only in bundled dependencies, none in TubeSage source: (1) `p-queue`'s concurrency timer (no network); (2) `langsmith`'s cache-refresh loop. `langsmith` is LangChain's optional tracing library, pulled in transitively via `@langchain/core`. It is dormant unless LangSmith tracing is explicitly enabled (env vars / API key) — TubeSage never does this. Fix: disclosure paragraph in README — Task 5.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1 — Mechanical fixes (one v1.2.28 release)
|
||||
|
||||
## Task 1: Add a GitHub-recognizable LICENSE file
|
||||
|
||||
The repo has `MIT-license-tubesage.md`, but GitHub's license detector needs a standard filename (`LICENSE`) containing only recognized license text. The existing file also bundles a "YouTube Content Usage Disclaimer" which breaks detection. Keep `MIT-license-tubesage.md` (the plugin displays it in-app); add a clean `LICENSE`.
|
||||
|
||||
**Files:**
|
||||
- Create: `LICENSE`
|
||||
|
||||
- [ ] **Step 1: Create the LICENSE file**
|
||||
|
||||
Create `LICENSE` with exactly this content (standard MIT, no markdown headers, no extra sections):
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Richard McCorkle
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `head -1 LICENSE`
|
||||
Expected: `MIT License`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add LICENSE
|
||||
git commit -m "chore: add standard LICENSE file for GitHub license detection"
|
||||
```
|
||||
|
||||
## Task 2: Remove unnecessary type assertion in fetch-shim.ts
|
||||
|
||||
`src/utils/fetch-shim.ts:174` has `(init.body as ArrayBufferView).buffer`. In the ternary's false branch, `init.body` is already narrowed to `ArrayBufferView` by the enclosing `ArrayBuffer.isView()` guard, so the assertion is redundant (flagged by `@typescript-eslint/no-unnecessary-type-assertion`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/fetch-shim.ts:170-174`
|
||||
|
||||
- [ ] **Step 1: Verify the lint warning reproduces locally**
|
||||
|
||||
Run: `npm run lint 2>&1 | grep -A1 fetch-shim`
|
||||
Expected: a `no-unnecessary-type-assertion` warning at line 174.
|
||||
|
||||
- [ ] **Step 2: Remove the assertion**
|
||||
|
||||
In `src/utils/fetch-shim.ts`, change this block:
|
||||
|
||||
```typescript
|
||||
} else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
|
||||
// Handle binary data
|
||||
options.arrayBuffer = init.body instanceof ArrayBuffer
|
||||
? init.body
|
||||
: (init.body as ArrayBufferView).buffer;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
} else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
|
||||
// Handle binary data
|
||||
options.arrayBuffer = init.body instanceof ArrayBuffer
|
||||
? init.body
|
||||
: init.body.buffer;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build + lint both pass**
|
||||
|
||||
Run: `npm run build && npm run lint 2>&1 | grep fetch-shim || echo "fetch-shim clean"`
|
||||
Expected: build succeeds (tsc + esbuild), and `fetch-shim clean` prints.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/fetch-shim.ts
|
||||
git commit -m "fix: remove unnecessary type assertion in fetch-shim"
|
||||
```
|
||||
|
||||
## Task 3: Fix CSS-lint quick wins (duplicate selectors + shorthand)
|
||||
|
||||
Three duplicate selectors and one redundant shorthand. These are safe edits — they merge declarations into the canonical block.
|
||||
|
||||
**Files:**
|
||||
- Modify: `styles.css` (lines 361-395, 504+, 653+, 1016-1022, 1048)
|
||||
|
||||
- [ ] **Step 1: Merge `.tubesage-settings-info-icon-with-tooltip::after`**
|
||||
|
||||
In the block at line 361, change `white-space: nowrap;` to `white-space: normal;` and add `width: max-content;` after it. Then delete the duplicate block (the `/* Handle long tooltips by allowing wrapping */` comment plus its rule, lines 390-395):
|
||||
|
||||
Delete:
|
||||
```css
|
||||
/* Handle long tooltips by allowing wrapping */
|
||||
.tubesage-settings-info-icon-with-tooltip::after {
|
||||
white-space: normal;
|
||||
width: max-content;
|
||||
max-width: 300px;
|
||||
}
|
||||
```
|
||||
|
||||
The line-380 declaration inside the 361 block changes from:
|
||||
```css
|
||||
white-space: nowrap;
|
||||
```
|
||||
to:
|
||||
```css
|
||||
white-space: normal;
|
||||
width: max-content;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Merge `.tubesage-license-container`**
|
||||
|
||||
Add `font-family: var(--font-monospace);` as the last declaration of the canonical block at line 504. Then delete the duplicate at lines 1016-1018:
|
||||
|
||||
Delete:
|
||||
```css
|
||||
.tubesage-license-container {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Merge `.tubesage-readme-container`**
|
||||
|
||||
Add `font-family: var(--font-interface);` as the last declaration of the canonical block at line 653. Then delete the duplicate at lines 1020-1022:
|
||||
|
||||
Delete:
|
||||
```css
|
||||
.tubesage-readme-container {
|
||||
font-family: var(--font-interface);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix redundant margin shorthand**
|
||||
|
||||
At line 1048 (`.tubesage-custom-params-header`), change:
|
||||
```css
|
||||
margin: 0 0 15px 0;
|
||||
```
|
||||
to:
|
||||
```css
|
||||
margin: 0 0 15px;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no duplicate selectors remain (for these three)**
|
||||
|
||||
Run: `grep -nc "tubesage-license-container {" styles.css; grep -nc "tubesage-readme-container {" styles.css`
|
||||
Expected: each selector now appears exactly once.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add styles.css
|
||||
git commit -m "fix: dedupe CSS selectors and redundant margin shorthand"
|
||||
```
|
||||
|
||||
## Task 4: Make release builds reproducible + add artifact attestation
|
||||
|
||||
`release.yml` uses `npm install` (unpinned) and creates releases without provenance attestation. Switch to `npm ci` (verified to reproduce the artifact byte-for-byte), add `actions/attest-build-provenance`, and modernize the action versions.
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/release.yml`
|
||||
|
||||
- [ ] **Step 1: Replace `.github/workflows/release.yml` with this content**
|
||||
|
||||
```yaml
|
||||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
styles.css
|
||||
manifest.json
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "Release $tag already exists, skipping draft creation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js styles.css manifest.json README.md LICENSE MIT-license-tubesage.md templates/YouTubeTranscript.md
|
||||
```
|
||||
|
||||
Changes: `checkout@v3→v4`, `setup-node@v3→v4`, `node 18.x→20.x`, `npm install→npm ci`, added attestation step + `id-token`/`attestations` permissions, added `LICENSE` to release assets.
|
||||
|
||||
- [ ] **Step 2: Verify YAML parses**
|
||||
|
||||
Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml')); print('valid')"`
|
||||
Expected: `valid`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/release.yml
|
||||
git commit -m "ci: use npm ci for reproducible builds and add artifact attestation"
|
||||
```
|
||||
|
||||
## Task 5: De-promote the README + fix license link + disclose setInterval
|
||||
|
||||
The portal flagged "excessive promotional language." Rewrite README.md factually: drop superlatives ("powerful", "cutting-edge", "state-of-the-art", "intelligent", "seamlessly", "Smart", "Advanced"), drop emoji section headers, drop the stale "Recent Updates (v1.0.6)" section and the closing marketing line. Fix the license link (`MIT-license-tubesage.md` → `LICENSE`). Add a factual disclosure paragraph about the bundled `langsmith` background timer.
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (full rewrite)
|
||||
|
||||
- [ ] **Step 1: Replace README.md with the de-promoted version**
|
||||
|
||||
Replace the entire file with this content:
|
||||
|
||||
````markdown
|
||||
# TubeSage
|
||||
|
||||
TubeSage is an Obsidian plugin that converts YouTube videos into structured notes using large language models. It extracts transcripts, generates summaries, and can add timestamped links back to specific moments in the video.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
**Community Plugins:** Install through Obsidian's Settings → Community plugins browser once TubeSage is listed in the directory.
|
||||
|
||||
**Manual install from a GitHub release:**
|
||||
1. Open the [latest release](https://github.com/rmccorkl/TubeSage/releases/latest) page.
|
||||
2. From the **Assets** section, download `main.js`, `manifest.json`, and `styles.css`.
|
||||
*Do not* download "Source code (zip)" or "Source code (tar.gz)" — those are source archives and will not load as a plugin.
|
||||
3. In your vault, create the folder `.obsidian/plugins/tubesage/`.
|
||||
4. Move the three downloaded files into that `tubesage/` folder.
|
||||
5. In Obsidian, go to Settings → Community plugins, toggle off "Restricted mode" if needed, refresh the installed-plugins list, and enable **TubeSage**.
|
||||
6. Accept the license terms in TubeSage's settings panel.
|
||||
7. Configure API keys for your preferred LLM provider.
|
||||
8. (For YouTube notes) Install the [Templater plugin](https://github.com/SilentVoid13/Templater) for template-driven formatting.
|
||||
|
||||
### Requirements
|
||||
- [Obsidian](https://obsidian.md/) v1.2.0+
|
||||
- [Templater plugin](https://github.com/SilentVoid13/Templater) (required for template functionality)
|
||||
- An API key for at least one LLM provider:
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google (Gemini)
|
||||
- OpenRouter
|
||||
- Ollama (local models)
|
||||
- A YouTube Data API key (optional — required only for channel/playlist processing)
|
||||
|
||||
## Features
|
||||
|
||||
- Transcript extraction from YouTube videos with fallback methods
|
||||
- Summary generation using a configurable LLM provider
|
||||
- Optional timestamp links added to section headings, linking to the moment in the video
|
||||
- Works on desktop and mobile Obsidian
|
||||
- Batch processing of YouTube channels and playlists
|
||||
- Multi-provider support: OpenAI, Anthropic, Google Gemini, OpenRouter, and Ollama
|
||||
- A cross-platform fetch shim so all providers work on mobile without Node.js dependencies
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. License acceptance
|
||||
Accept the MIT license terms in the settings panel before using the plugin. Use the "View License" button to read the full text.
|
||||
|
||||
### 2. LLM provider setup
|
||||
Choose and configure a provider in settings:
|
||||
|
||||
- **OpenAI** — models such as GPT-4o, GPT-4, GPT-3.5-Turbo.
|
||||
- **Anthropic** — Claude 3 family models.
|
||||
- **Google Gemini** — Gemini Pro models.
|
||||
- **OpenRouter** — gateway access to models from multiple vendors.
|
||||
- **Ollama** — local open-source models; runs offline, requires Ollama installed and running.
|
||||
|
||||
### 3. Other settings
|
||||
- **Summary modes**: Fast (brief) or Extensive (detailed).
|
||||
- **Timestamp processing**: enable or disable automatic timestamp links.
|
||||
- **Batch processing**: sequential or parallel processing for collections.
|
||||
- **Performance monitoring**: optional logging of processing times.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic workflow
|
||||
1. Click the YouTube icon in the ribbon, or use the Command Palette.
|
||||
2. Paste a YouTube video URL.
|
||||
3. Choose a summary mode and folder location.
|
||||
4. Run processing.
|
||||
5. The resulting note appears in the chosen folder.
|
||||
|
||||
### Batch processing (channels/playlists)
|
||||
1. Configure a YouTube Data API key in settings.
|
||||
2. Paste a channel or playlist URL.
|
||||
3. Set the number of videos to process.
|
||||
4. Choose sequential or parallel processing.
|
||||
|
||||
### Timestamp navigation
|
||||
When enabled, each section heading includes a link that opens the YouTube video at the corresponding moment.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
- **Main plugin** (`main.ts`): coordinator and UI.
|
||||
- **Transcript extractor** (`src/youtube-transcript.ts`): multi-method extraction with mobile fallbacks.
|
||||
- **LLM factory** (`src/llm/llm-factory.ts`): constructs the provider client.
|
||||
- **Transcript summarizer** (`src/llm/transcript-summarizer.ts`): orchestrates summarization.
|
||||
- **LangChain client** (`src/llm/langchain-client.ts`): unified interface for cloud providers.
|
||||
- **Fetch shim** (`src/utils/fetch-shim.ts`): cross-platform HTTP via Obsidian's `requestUrl`.
|
||||
- **Timestamp processor** (`src/utils/timestamp-utils.ts`): timestamp link generation.
|
||||
- **Error utilities** (`src/utils/error-utils.ts`): error categorization and retry logic.
|
||||
|
||||
Architecture diagrams are in the `docs/` directory:
|
||||
- [Workflow Diagram](docs/workflow-diagram.md)
|
||||
- [Data Flow Diagram](docs/data-flow-diagram.md)
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- API calls to LLM providers and YouTube use HTTPS.
|
||||
- No user data is stored on servers operated by the plugin author.
|
||||
- Ollama can be used for fully local, offline processing.
|
||||
- The plugin bundles `langsmith` as a transitive dependency of `@langchain/core`. `langsmith` is LangChain's optional tracing library and contains a background cache-refresh timer. TubeSage never enables LangSmith tracing and never sets a LangSmith API key, so this code stays dormant and performs no background network transmission. The only network requests TubeSage makes are to the LLM provider you configure and to YouTube.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **API key errors**: verify keys are configured correctly in settings.
|
||||
- **Mobile processing issues**: ensure a stable connection, or use Ollama for offline processing.
|
||||
- **Timestamp link failures**: check video availability and URL format.
|
||||
- **Batch processing limits**: monitor YouTube API quota usage.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file. A YouTube content-usage disclaimer is included in [MIT-license-tubesage.md](MIT-license-tubesage.md).
|
||||
|
||||
## Support & Contribution
|
||||
|
||||
- **GitHub Issues**: bug reports and feature requests.
|
||||
- **Code contributions**: fork the repository and open a pull request.
|
||||
|
||||
---
|
||||
|
||||
**GitHub Repository**: [https://github.com/rmccorkl/TubeSage](https://github.com/rmccorkl/TubeSage)
|
||||
````
|
||||
|
||||
- [ ] **Step 2: Verify the license link and no leftover emoji headers**
|
||||
|
||||
Run: `grep -n "MIT-license-tubesage.md)" README.md; grep -cE '^#+ .*[🚀✨🤖📱⚡🔧📋🏗🎯📚💼🎓📖🔄🔐🎨🆘📄🤝]' README.md`
|
||||
Expected: the license-link grep shows the disclaimer reference line only; the emoji-header count is `0`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: de-promote README, fix license link, disclose bundled langsmith timer"
|
||||
```
|
||||
|
||||
## Task 6: Bump version to 1.2.28
|
||||
|
||||
**Files:**
|
||||
- Modify: `manifest.json`, `package.json`
|
||||
|
||||
- [ ] **Step 1: Bump `manifest.json`**
|
||||
|
||||
Change `"version": "1.2.27"` to `"version": "1.2.28"`.
|
||||
|
||||
- [ ] **Step 2: Bump `package.json`**
|
||||
|
||||
Change `"version": "1.2.27"` to `"version": "1.2.28"`.
|
||||
|
||||
- [ ] **Step 3: Verify a clean reproducible build**
|
||||
|
||||
Run: `npm ci && npm run build && echo "build OK"`
|
||||
Expected: `build OK`. (`postbuild` runs `npm run audit:fallow` — if fallow flags anything, address separately; the build itself must succeed.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add manifest.json package.json
|
||||
git commit -m "chore: bump version to 1.2.28"
|
||||
```
|
||||
|
||||
## Task 7: Tag and release v1.2.28
|
||||
|
||||
This follows the user's established release flow (see memory `feedback_release_workflow.md`): push commits, push tag, the CI workflow builds and drafts the release, then publish it.
|
||||
|
||||
- [ ] **Step 1: Push commits and tag**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git tag 1.2.28
|
||||
git push origin 1.2.28
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wait for the release workflow, then publish**
|
||||
|
||||
The `release.yml` workflow builds with `npm ci`, generates attestation, and creates a **draft** release. Verify it succeeded:
|
||||
|
||||
Run: `gh run list --workflow="Release Obsidian plugin" --limit 1`
|
||||
Then publish the draft:
|
||||
```bash
|
||||
gh release edit 1.2.28 --draft=false
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the published release has the required assets**
|
||||
|
||||
Run: `gh release view 1.2.28 --json assets -q '.assets[].name'`
|
||||
Expected: includes `main.js`, `manifest.json`, `styles.css`.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 2 — CSS `!important` + `:has()` refactor (approach, not pre-baked code)
|
||||
|
||||
**Why this is separate:** the review flagged ~100 lines using `!important` and 6 uses of `:has()`. Removing `!important` is only correct if the rule still wins against Obsidian's theme styles after removal — and that can only be confirmed by loading the plugin in Obsidian and visually inspecting the settings panel and modals. Pre-writing 100 line-by-line replacements would be fake planning. **This phase cannot be auto-verified; it requires manual visual testing in Obsidian.**
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. **Create an isolated worktree** for this work (`superpowers:using-git-worktrees`).
|
||||
2. **`:has()` first — eliminate it via a class.** The 6 `:has(.tubesage-prompt-textarea)` rules style a `.setting-item` based on a child. Instead, find where the prompt-textarea settings are constructed in `main.ts` and call `setting.settingEl.addClass('tubesage-prompt-setting')` (or equivalent) at construction. Then rewrite the 6 selectors from `.setting-item:has(.tubesage-prompt-textarea)` to `.setting-item.tubesage-prompt-setting`. This is deterministic and removes the `:has()` performance warning entirely.
|
||||
3. **`!important` — remove then re-qualify.** Delete every `!important`, rebuild, load the plugin in Obsidian, and inspect: settings panel headings, provider rows, prompt textareas, and the license/readme/template modals. For each rule that now loses to the theme, raise specificity deliberately — stack the plugin's own class (`.tubesage-x.tubesage-x`) or add a parent scope (`.workspace-leaf-content .tubesage-x`) — rather than restoring `!important`. Many rules (e.g. `.tubesage-readme-modal-size.modal`) already have enough specificity and only need the `!important` deleted.
|
||||
4. **Verify visually** in Obsidian desktop *and* mobile (or the mobile emulator), since several `!important` rules live inside the `@media (max-width: 768px)` block.
|
||||
5. Ship in a follow-up release (v1.2.29) — do not block Phase 1 / v1.2.28 on it.
|
||||
|
||||
**Risk:** medium-high. The settings UI is the user-facing surface; a botched specificity change is visible. Worktree isolation + visual testing is mandatory.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 3 — setInterval finding (RESOLVED)
|
||||
|
||||
No code task. Investigation complete (see "Investigation findings" above). The disclosure is delivered by Phase 1 Task 5's README Privacy section. If the portal still flags it after v1.2.28, the fallback is to respond on the portal dashboard citing the disclosure — `langsmith` cannot be removed without dropping `@langchain/core`, which the architecture mandate requires.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** License ✓ (T1); type assertion ✓ (T2); duplicate selectors + shorthand ✓ (T3); build verification + attestation ✓ (T4); README promo language ✓ (T5); langsmith ✓ (resolved, ships via T6/T7 release); setInterval ✓ (T5 disclosure + Phase 3); `!important` + `:has()` → Phase 2. All 8 review categories accounted for.
|
||||
- **Placeholder scan:** none — every step has concrete content. Phase 2 is deliberately an approach (justified inline), not placeholder tasks.
|
||||
- **Type consistency:** Task 2's edit keeps `options.arrayBuffer`'s assignment type-correct (verified by the Step 3 build gate). No cross-task symbol mismatches.
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
# Processing Spinner Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace TubeSage's in-modal CSS pulse-bar animation with a Braille-dots spinner — in the status bar on desktop (adapted from TubeSage-Wiki-Pro), in-modal on mobile (which has no status bar).
|
||||
|
||||
**Architecture:** A single platform-aware `ProcessingSpinner` class. `start()` mounts the spinner — a status-bar item on desktop, a text element inside the processing modal on mobile — and animates the same Braille frames via `setInterval`. The two existing pulse-bar call sites in `main.ts` switch to it; the pulse CSS is removed.
|
||||
|
||||
**Tech Stack:** TypeScript, esbuild, Obsidian plugin API (`addStatusBarItem`, `Platform`).
|
||||
|
||||
**Testing note:** No automated test harness exists (`npm test` is undefined). Verification is `npm run build` plus the manual checks in Task 4.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/utils/processing-spinner.ts` | New — the `ProcessingSpinner` class |
|
||||
| `main.ts` | Replace 2 pulse-bar blocks (~3137, ~3461) with `ProcessingSpinner`; stop it in a `finally` |
|
||||
| `styles.css` | Remove `.pulse-container` / `.pulse-bar` / `@keyframes pulse` (lines ~261-293 and the `@media` variant ~1068-1084); add `.tubesage-spinner` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create the ProcessingSpinner class
|
||||
|
||||
**Files:**
|
||||
- Create: `src/utils/processing-spinner.ts`
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
Create `src/utils/processing-spinner.ts` with exactly:
|
||||
|
||||
```typescript
|
||||
import { Plugin, Platform } from "obsidian";
|
||||
|
||||
// Braille-dots spinner — ten frames, animates smoothly. Ticks every 100ms
|
||||
// while a long-running operation is active so the user sees the plugin is
|
||||
// alive even during a single long LLM call.
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const SPINNER_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* Platform-aware processing spinner.
|
||||
*
|
||||
* Desktop: drives a status-bar item (Obsidian's bottom status bar).
|
||||
* Mobile: Obsidian has no status bar, so it renders a text element inside
|
||||
* the supplied modal content element instead. Same Braille frames either way.
|
||||
*
|
||||
* Lifecycle: `start()` mounts and animates; `setLabel(text)` updates the
|
||||
* per-step label and bumps the call counter; `stop()` removes the spinner
|
||||
* and clears the interval. Safe to call `stop()` more than once.
|
||||
*/
|
||||
export class ProcessingSpinner {
|
||||
private statusBarItem: HTMLElement | null = null;
|
||||
private modalSpinnerEl: HTMLElement | null = null;
|
||||
private callCount = 0;
|
||||
private currentLabel: string;
|
||||
private spinnerFrame = 0;
|
||||
private spinnerHandle: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly plugin: Plugin,
|
||||
private readonly prefix: string,
|
||||
private readonly modalContentEl: HTMLElement,
|
||||
initialLabel = "starting…",
|
||||
) {
|
||||
this.currentLabel = initialLabel;
|
||||
}
|
||||
|
||||
/** Mounts the spinner (status bar on desktop, in-modal on mobile) and starts animating. */
|
||||
start(): void {
|
||||
if (Platform.isMobile) {
|
||||
this.modalSpinnerEl = this.modalContentEl.createDiv({ cls: "tubesage-spinner" });
|
||||
} else {
|
||||
this.statusBarItem = this.plugin.addStatusBarItem();
|
||||
}
|
||||
this.render();
|
||||
this.spinnerHandle = activeWindow.setInterval(() => {
|
||||
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
||||
this.render();
|
||||
}, SPINNER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Update the per-step label and bump the call counter. */
|
||||
setLabel(label: string): void {
|
||||
if (label) this.currentLabel = label;
|
||||
this.callCount += 1;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Removes the spinner and stops the animation. Safe to call more than once. */
|
||||
stop(): void {
|
||||
if (this.spinnerHandle !== null) {
|
||||
activeWindow.clearInterval(this.spinnerHandle);
|
||||
this.spinnerHandle = null;
|
||||
}
|
||||
if (this.statusBarItem) {
|
||||
this.statusBarItem.remove();
|
||||
this.statusBarItem = null;
|
||||
}
|
||||
if (this.modalSpinnerEl) {
|
||||
this.modalSpinnerEl.remove();
|
||||
this.modalSpinnerEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const spinner = SPINNER_FRAMES[this.spinnerFrame];
|
||||
const text = `${spinner} ${this.prefix} · ${this.currentLabel} · #${this.callCount}`;
|
||||
if (this.statusBarItem) this.statusBarItem.setText(text);
|
||||
if (this.modalSpinnerEl) this.modalSpinnerEl.setText(text);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds (file compiles; unused until Task 2).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/processing-spinner.ts
|
||||
git commit -m "feat: add platform-aware ProcessingSpinner (Braille status-bar / in-modal)"
|
||||
```
|
||||
|
||||
## Task 2: Wire ProcessingSpinner into the two processing flows
|
||||
|
||||
`main.ts` creates a `.pulse-container` with 5 `.pulse-bar` divs in two places:
|
||||
`beginCollectionProcessing` (~line 3137) and the single-video processing flow
|
||||
(~line 3461). Both run inside an `async` method with a `try { this.isProcessing = true; ... }`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — import (line 1 area), and the two blocks
|
||||
|
||||
- [ ] **Step 1: Add the import**
|
||||
|
||||
Near the other `src/utils` imports at the top of `main.ts`, add:
|
||||
|
||||
```typescript
|
||||
import { ProcessingSpinner } from "./src/utils/processing-spinner";
|
||||
```
|
||||
|
||||
(Match the existing relative-path style of neighboring `src/` imports in `main.ts`.)
|
||||
|
||||
- [ ] **Step 2: Replace the first pulse block (`beginCollectionProcessing`, ~3137-3145)**
|
||||
|
||||
Replace:
|
||||
|
||||
```typescript
|
||||
// Create a minimal container just for centering the pulse bars
|
||||
const pulseContainerEl = contentEl.createDiv({
|
||||
cls: 'pulse-container'
|
||||
});
|
||||
|
||||
// Just create the pulse bars
|
||||
for (let i = 0; i < 5; i++) {
|
||||
pulseContainerEl.createDiv({ cls: 'pulse-bar' });
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
// Braille-dots processing spinner (status bar on desktop, in-modal on mobile)
|
||||
const spinner = new ProcessingSpinner(this.plugin, 'Processing collection', contentEl);
|
||||
spinner.start();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ensure the first spinner stops**
|
||||
|
||||
`beginCollectionProcessing` wraps its work in `try { ... }`. Find the matching
|
||||
`catch`/end of that `try` block and ensure the method stops the spinner on every
|
||||
exit path by adding a `finally` block (or extending the existing one) that calls:
|
||||
|
||||
```typescript
|
||||
} finally {
|
||||
spinner.stop();
|
||||
}
|
||||
```
|
||||
|
||||
If a `finally` already exists, add `spinner.stop();` to it. The `spinner` const is
|
||||
in scope for the whole `try` because it is declared at the top of the `try` body.
|
||||
|
||||
- [ ] **Step 4: Replace the second pulse block (single-video flow, ~3461-3469)**
|
||||
|
||||
Replace the identical block:
|
||||
|
||||
```typescript
|
||||
// Create a minimal container just for centering the pulse bars
|
||||
const pulseContainerEl = contentEl.createDiv({
|
||||
cls: 'pulse-container'
|
||||
});
|
||||
|
||||
// Just create the pulse bars
|
||||
for (let i = 0; i < 5; i++) {
|
||||
pulseContainerEl.createDiv({ cls: 'pulse-bar' });
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
// Braille-dots processing spinner (status bar on desktop, in-modal on mobile)
|
||||
const spinner = new ProcessingSpinner(this.plugin, 'Processing video', contentEl);
|
||||
spinner.start();
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Ensure the second spinner stops**
|
||||
|
||||
As in Step 3, ensure the single-video flow's `try` block has a `finally` that calls
|
||||
`spinner.stop();` on every exit path.
|
||||
|
||||
- [ ] **Step 6: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds; no `pulse-container` / `pulse-bar` references remain in `main.ts`
|
||||
(`grep -c "pulse-" main.ts` returns 0).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat: use ProcessingSpinner in collection and single-video flows"
|
||||
```
|
||||
|
||||
## Task 3: Remove pulse CSS, add the mobile spinner rule
|
||||
|
||||
**Files:**
|
||||
- Modify: `styles.css`
|
||||
|
||||
- [ ] **Step 1: Delete the main pulse rules**
|
||||
|
||||
Remove this entire block (currently ~lines 261-293):
|
||||
|
||||
```css
|
||||
.pulse-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pulse-bar {
|
||||
width: 8px;
|
||||
height: 40px;
|
||||
margin: 0 3px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--interactive-accent);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pulse-bar:nth-child(1) { animation-delay: 0s; }
|
||||
.pulse-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||
.pulse-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||
.pulse-bar:nth-child(4) { animation-delay: 0.6s; }
|
||||
.pulse-bar:nth-child(5) { animation-delay: 0.8s; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { height: 5px; opacity: 0.3; }
|
||||
50% { height: 40px; opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the `@media` pulse rules**
|
||||
|
||||
Inside the `@media (max-width: 768px)` block, remove the `.pulse-container`,
|
||||
`.pulse-bar`, and `@keyframes pulse` rules (and their explanatory comments). Read
|
||||
the file to find their exact current lines — leave the rest of the `@media` block intact.
|
||||
|
||||
- [ ] **Step 3: Add the mobile spinner rule**
|
||||
|
||||
Add this rule (place it where the `.pulse-container` block was):
|
||||
|
||||
```css
|
||||
.tubesage-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
min-height: 40px;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `grep -c "pulse" styles.css`
|
||||
Expected: `0`.
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add styles.css
|
||||
git commit -m "fix: remove pulse-bar CSS, add tubesage-spinner rule"
|
||||
```
|
||||
|
||||
## Task 4: Manual verification
|
||||
|
||||
- [ ] **Step 1: Build** — `npm run build` succeeds.
|
||||
- [ ] **Step 2: Desktop** — process a video in desktop Obsidian; confirm a Braille spinner
|
||||
animates in the status bar and disappears when processing ends.
|
||||
- [ ] **Step 3: Mobile** — process a video in mobile Obsidian; confirm a Braille spinner
|
||||
animates inside the processing modal (no status bar there) and disappears when done.
|
||||
- [ ] **Step 4: No leak** — after processing completes on desktop, confirm the status-bar
|
||||
item is gone (not left behind).
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** desktop status-bar spinner (Task 1 + 2), mobile in-modal spinner
|
||||
(Task 1 `Platform.isMobile` branch + Task 3 CSS), pulse removal (Task 3). Covered.
|
||||
- **Placeholder scan:** Steps 3, 5, and Task 3 Step 2 reference "find the exact lines"
|
||||
rather than pre-quoting — this is deliberate: the surrounding `try`/`finally` and the
|
||||
`@media` block contents must be read live so the edit is correct against current code.
|
||||
Every code-producing step has complete code.
|
||||
- **Type consistency:** `ProcessingSpinner` constructor `(plugin, prefix, modalContentEl, initialLabel?)`
|
||||
is used consistently in Task 2 with 3 args. `start()`/`stop()` names match between Tasks 1 and 2.
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
# Secret Storage for API Keys — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move TubeSage's OpenAI/Anthropic/Google/OpenRouter API keys out of the plaintext `data.json` into Obsidian's `app.secretStorage`, with a one-time migration that scrubs existing keys.
|
||||
|
||||
**Architecture:** `settings.apiKeys` stays as a runtime object but its 4 cloud-provider entries are never persisted — they are populated from `secretStorage` on load and stripped before every save. Existing read sites are untouched; only load, save, and the API-key settings UI change. Spec: `docs/superpowers/specs/2026-05-16-secret-storage-api-keys-design.md`.
|
||||
|
||||
**Tech Stack:** TypeScript, esbuild, Obsidian plugin API (`app.secretStorage`, `@since 1.11.4`).
|
||||
|
||||
**Note on testing:** The project has no automated test harness (`npm test` is not defined). Verification for every task is `npm run build` (runs `tsc -noEmit -skipLibCheck` + esbuild) plus the manual checks in Task 6.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `main.ts` | Add module constants; add `persist()` helper; route saves through it; extend `loadSettings()` with migration + population; update `createProviderApiKeyRow` onChange |
|
||||
| `manifest.json` | `minAppVersion` `1.2.0` → `1.11.4` |
|
||||
| `package.json` | obsidian devDependency `^1.8.7` → `^1.11.4` |
|
||||
|
||||
No new files. `transcript-summarizer.ts` and `llm-factory.ts` are not touched.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add module constants for cloud providers and secret IDs
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` (immediately after the `DEFAULT_SETTINGS` object, which ends near line 460)
|
||||
|
||||
- [ ] **Step 1: Add the constants**
|
||||
|
||||
After the `DEFAULT_SETTINGS` declaration (find the line `};` that closes `const DEFAULT_SETTINGS`), add:
|
||||
|
||||
```typescript
|
||||
// Cloud LLM providers whose API keys are secrets stored in Obsidian's
|
||||
// secret storage. 'ollama' is intentionally excluded — its apiKeys entry
|
||||
// is a server URL, not a secret, and stays in data.json.
|
||||
const CLOUD_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter'] as const;
|
||||
|
||||
// Secret-storage IDs, one per cloud provider. IDs must be lowercase
|
||||
// alphanumeric with optional dashes (required by SecretStorage.setSecret).
|
||||
const SECRET_IDS: Record<string, string> = {
|
||||
openai: 'tubesage-openai-key',
|
||||
anthropic: 'tubesage-anthropic-key',
|
||||
google: 'tubesage-google-key',
|
||||
openrouter: 'tubesage-openrouter-key',
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds (the constants are unused so far — that is fine; `tsc` does not error on unused module-level `const`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat: add cloud-provider and secret-id constants"
|
||||
```
|
||||
|
||||
## Task 2: Add a `persist()` helper that strips cloud keys before saving
|
||||
|
||||
The plugin currently writes settings via `this.saveData(this.settings)` in two places — `saveSettings()` and the `customModelLimits` migration inside `loadSettings()`. Both must go through a helper that removes the 4 cloud keys so `data.json` never contains them.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `saveSettings()` (near line 676) and the `customModelLimits` migration save (near line 672)
|
||||
|
||||
- [ ] **Step 1: Add the `persist()` method**
|
||||
|
||||
Directly above the existing `async saveSettings()` method, add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Persist settings to data.json with cloud-provider API keys stripped out.
|
||||
* Cloud keys live in Obsidian secret storage, never in data.json.
|
||||
*/
|
||||
private async persist(): Promise<void> {
|
||||
const sanitizedApiKeys: Record<string, string> = {
|
||||
ollama: this.settings.apiKeys.ollama ?? DEFAULT_SETTINGS.apiKeys.ollama,
|
||||
};
|
||||
const toSave = { ...this.settings, apiKeys: sanitizedApiKeys };
|
||||
await this.saveData(toSave);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Route `saveSettings()` through `persist()`**
|
||||
|
||||
Replace the body of `saveSettings()`:
|
||||
|
||||
```typescript
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
this.initializeSummarizer();
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
async saveSettings() {
|
||||
await this.persist();
|
||||
this.initializeSummarizer();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Route the `customModelLimits` migration save through `persist()`**
|
||||
|
||||
Inside `loadSettings()`, in the `customModelLimits` cleanup block, find:
|
||||
|
||||
```typescript
|
||||
await this.saveData(this.settings);
|
||||
```
|
||||
|
||||
(it is the line right after the `for (const key of polluted)` delete loop). Replace it with:
|
||||
|
||||
```typescript
|
||||
await this.persist();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat: strip cloud API keys from persisted settings via persist() helper"
|
||||
```
|
||||
|
||||
## Task 3: Migrate and populate cloud keys in `loadSettings()`
|
||||
|
||||
On load: migrate any cloud keys still in `data.json` into secret storage, then populate the runtime `settings.apiKeys` cloud entries from secret storage. If `data.json` contained any cloud keys, persist once to scrub them.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `loadSettings()`, after the `customModelLimits` migration block, before the method's closing brace
|
||||
|
||||
- [ ] **Step 1: Add the migration + population block**
|
||||
|
||||
In `loadSettings()`, after the entire `customModelLimits` cleanup `if (polluted.length > 0) { ... }` block and before the closing `}` of `loadSettings()`, add:
|
||||
|
||||
```typescript
|
||||
// --- API key secret-storage migration ---------------------------------
|
||||
// Cloud-provider keys live in Obsidian secret storage, not data.json.
|
||||
// 1. Migrate any key still present in data.json into secret storage.
|
||||
// 2. Populate the runtime settings.apiKeys cloud entries from storage.
|
||||
// 3. If data.json held any cloud key, persist once to scrub it out.
|
||||
const dataApiKeys: Record<string, string> =
|
||||
isRecord(loadedSettings.apiKeys) ? (loadedSettings.apiKeys as Record<string, string>) : {};
|
||||
let hadCloudKeysInData = false;
|
||||
for (const provider of CLOUD_PROVIDERS) {
|
||||
const fromData = dataApiKeys[provider];
|
||||
if (typeof fromData === 'string' && fromData.trim() !== '') {
|
||||
hadCloudKeysInData = true;
|
||||
if (!this.app.secretStorage.getSecret(SECRET_IDS[provider])) {
|
||||
this.app.secretStorage.setSecret(SECRET_IDS[provider], fromData);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const provider of CLOUD_PROVIDERS) {
|
||||
this.settings.apiKeys[provider] =
|
||||
this.app.secretStorage.getSecret(SECRET_IDS[provider]) ?? '';
|
||||
}
|
||||
if (hadCloudKeysInData) {
|
||||
logger.info('[migration] Moved cloud API keys to secret storage; scrubbing data.json');
|
||||
await this.persist();
|
||||
}
|
||||
// ----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
Note: `isRecord` and `logger` are already defined/imported in `main.ts` (used elsewhere in `loadSettings()`).
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat: migrate and load cloud API keys from secret storage"
|
||||
```
|
||||
|
||||
## Task 4: Write cloud keys to secret storage from the settings UI
|
||||
|
||||
The API-key text field's `onChange` currently writes only to `settings.apiKeys[provider]`. For cloud providers it must also write secret storage. The field's description text is updated to be accurate.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `createProviderApiKeyRow` (near lines 4283-4309)
|
||||
|
||||
- [ ] **Step 1: Update the description text**
|
||||
|
||||
In `createProviderApiKeyRow`, replace:
|
||||
|
||||
```typescript
|
||||
.setDesc('Stored in plugin data; available to all summarisation flows.')
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
.setDesc(
|
||||
provider === 'ollama'
|
||||
? 'Server URL, stored in plugin data.'
|
||||
: 'Stored in Obsidian secret storage, not in plugin data.'
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the `onChange` handler**
|
||||
|
||||
Replace:
|
||||
|
||||
```typescript
|
||||
.onChange((value: string) => {
|
||||
void (async () => {
|
||||
this.plugin.settings.apiKeys[provider] = value;
|
||||
await this.plugin.saveSettings();
|
||||
})();
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
.onChange((value: string) => {
|
||||
void (async () => {
|
||||
this.plugin.settings.apiKeys[provider] = value;
|
||||
if (CLOUD_PROVIDERS.includes(provider as typeof CLOUD_PROVIDERS[number])) {
|
||||
this.plugin.app.secretStorage.setSecret(SECRET_IDS[provider], value);
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
})();
|
||||
});
|
||||
```
|
||||
|
||||
The `setValue(this.plugin.settings.apiKeys[provider])` call above it is unchanged — the in-memory value was populated from secret storage in `loadSettings()`.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat: write cloud API keys to secret storage from settings UI"
|
||||
```
|
||||
|
||||
## Task 5: Bump `minAppVersion` and the obsidian devDependency
|
||||
|
||||
`app.secretStorage` is `@since 1.11.4`, so the plugin can no longer support older Obsidian.
|
||||
|
||||
**Files:**
|
||||
- Modify: `manifest.json`, `package.json`
|
||||
|
||||
- [ ] **Step 1: Bump `manifest.json`**
|
||||
|
||||
Change `"minAppVersion": "1.2.0"` to `"minAppVersion": "1.11.4"`.
|
||||
|
||||
- [ ] **Step 2: Bump `package.json`**
|
||||
|
||||
In `devDependencies`, change `"obsidian": "^1.8.7"` to `"obsidian": "^1.11.4"`.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `npm install && npm run build`
|
||||
Expected: `npm install` updates the lockfile if needed; build succeeds (the installed obsidian types are already 1.12.3, which contains `SecretStorage`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add manifest.json package.json package-lock.json
|
||||
git commit -m "chore: require Obsidian 1.11.4 for secret storage API"
|
||||
```
|
||||
|
||||
## Task 6: Manual verification
|
||||
|
||||
No code changes — this is the acceptance check. Build, deploy to a test vault, and confirm behavior.
|
||||
|
||||
- [ ] **Step 1: Build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: succeeds.
|
||||
|
||||
- [ ] **Step 2: Fresh-key check**
|
||||
|
||||
Load the plugin in an Obsidian 1.11.4+ vault. In settings, enter an OpenAI API key. Confirm:
|
||||
- `data.json` (`.obsidian/plugins/tubesage/data.json`) does NOT contain the key — its `apiKeys` object should hold only `ollama`.
|
||||
- The key resolves after an Obsidian restart (still shown in settings, summaries still authenticate).
|
||||
|
||||
- [ ] **Step 3: Migration check**
|
||||
|
||||
Start from a `data.json` whose `apiKeys` contains a non-empty `openai` key (simulating an upgrade). Load the plugin. Confirm:
|
||||
- After load, `data.json`'s `apiKeys` no longer contains the `openai` key (only `ollama` remains).
|
||||
- The key still works (settings shows it; a summary authenticates).
|
||||
|
||||
- [ ] **Step 4: Ollama untouched**
|
||||
|
||||
Confirm the Ollama server URL still saves to and loads from `data.json` as before.
|
||||
|
||||
- [ ] **Step 5: Run a summary** with a cloud provider to confirm end-to-end auth works.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** Secret IDs → Task 1. Runtime `apiKeys` + load/save behavior → Tasks 2, 3. Migration → Task 3. Settings UI → Task 4. Version bumps → Task 5. Edge cases (`getSecret` null → `''`) → handled by `?? ''` in Task 3 and the in-memory value in Task 4. All spec sections covered.
|
||||
- **Placeholder scan:** none — every step has concrete code and exact commands.
|
||||
- **Type consistency:** `CLOUD_PROVIDERS` and `SECRET_IDS` (Task 1) are referenced consistently in Tasks 3 and 4. `persist()` (Task 2) is called in Tasks 2 and 3. `SECRET_IDS` is typed `Record<string, string>` so `SECRET_IDS[provider]` indexing in Tasks 3-4 type-checks.
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
# TubeSage UI Native Alignment Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Tighten the TubeSage plugin UI so it reads as a native Obsidian surface: replace hand-rolled controls with Obsidian's component primitives, kill theme-breaking hardcoded values, and put spacing on Obsidian's variable scale.
|
||||
|
||||
**Architecture:** Three sequential tasks ordered so component replacement happens before the CSS sweep (replacing custom controls deletes CSS, so the final CSS pass runs on what survives). Surfaces: the create-note `Modal`, the `PluginSettingTab`, then a whole-file `styles.css` design-system pass. No behavior changes; only presentation and the markup that produces it.
|
||||
|
||||
**Tech Stack:** TypeScript, Obsidian Plugin API (`Setting`, `ToggleComponent`, `mod-cta`), esbuild. Verification is `npm run build` (tsc strict + esbuild bundle). There is no UI test framework; each task ends with a build and a manual checklist.
|
||||
|
||||
**Design rules (apply in every task):**
|
||||
- Never hardcode a color. Obsidian theme variables only: `--text-accent`, `--text-normal`, `--text-muted`, `--text-on-accent`, `--background-*`, `--interactive-accent`.
|
||||
- Never `#fff` / `#000` / `white` / `black`. Toggle knobs and accents come from theme variables or native components.
|
||||
- Spacing uses Obsidian's scale, not ad-hoc pixels: `--size-4-1` (4px), `--size-4-2` (8px), `--size-4-3` (12px), `--size-4-4` (16px), `--size-4-5` (20px), `--size-4-6` (24px). Map existing values to the nearest step (15px→`--size-4-4`, 10px→`--size-4-2` or `--size-4-3`, 5px→`--size-4-1`).
|
||||
- Radius: `--radius-s` for inputs/buttons/small controls, `--radius-m` for panels/containers. Replace the ad-hoc `4px/5px/6px/8px` mix.
|
||||
- Shadows: `--shadow-s` / `--shadow-l`. No hand-rolled `rgba()` box-shadows.
|
||||
- No inline styles set via JS (`attr: { style: ... }`). The project CLAUDE.md bans this. Use a CSS class.
|
||||
- Prefer Obsidian native components over custom CSS replicas. `Setting`, `ToggleComponent`, `ButtonComponent`, and the `mod-cta` class already match the theme in every Obsidian theme.
|
||||
- Do not change any logic, settings keys, event behavior, or copy. Presentation only.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Modal native components
|
||||
|
||||
The create-note modal (`YouTubeTranscriptModal`, `main.ts:2767`) is a hand-built form. It ships a CSS-only toggle switch and a custom-styled process button that both duplicate Obsidian primitives, plus five inline-style strings that duplicate classes already in `styles.css`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `YouTubeTranscriptModal.buildInputStage()` approx `main.ts:2835-3015`
|
||||
- Modify: `styles.css` — toggle and process-button rules
|
||||
|
||||
- [ ] **Step 1: Replace the custom toggle switch with a native Obsidian toggle**
|
||||
|
||||
In `buildInputStage()` (`main.ts` ~2987-3014) the "Fast summary mode" control is built from `<label class="toggle-switch">` + hidden checkbox + `<span class="toggle-slider">`. Replace the `toggleSwitch`/`fastSummaryToggleEl`/`toggle-slider` markup with Obsidian's `ToggleComponent`:
|
||||
- Add `ToggleComponent` to the `obsidian` import on `main.ts:1`.
|
||||
- Build the toggle as `const fastToggle = new ToggleComponent(toggleContainer);` then `fastToggle.setValue(this.plugin.settings.useFastSummary);` and `fastToggle.onChange(value => { this.plugin.settings.useFastSummary = value; void this.plugin.saveSettings(); });`
|
||||
- The class field `fastSummaryToggleEl: HTMLInputElement` (`main.ts:2774`) becomes unused inside the modal. Search the class for other reads of `fastSummaryToggleEl` first; if none remain, delete the field, otherwise replace those reads with `fastToggle.getValue()`. Keep `.toggle-container` / `.toggle-label` / `.summary-info` — they still lay out the row.
|
||||
|
||||
- [ ] **Step 2: Replace the custom process button with a native CTA button**
|
||||
|
||||
The process button (`main.ts:2955`) uses `cls: 'tubesage-process-btn'` and, on mobile, `tubesage-process-btn-mobile`. Obsidian's native primary-button class is `mod-cta`. Change the button creation to `cls: ['mod-cta']` (keep the mobile full-width class). Keep the existing `addEventListener('click', ...)` untouched.
|
||||
|
||||
- [ ] **Step 3: Remove the five inline-style duplications in the modal**
|
||||
|
||||
`main.ts` lines ~2876, ~2882, ~2905, ~2911, ~2952 each pass `attr: { style: ... }` while also attaching a class that already defines the same flex layout in `styles.css`. Delete the `attr: { style: ... }` from each `createDiv` call. The classes (`tubesage-modal-controls-container` + desktop/mobile variant, `tubesage-modal-radio-option`, `tubesage-modal-limited-option-container`, `tubesage-modal-process-btn-container`) already cover the layout. Verify each class's rules in `styles.css:717-778` actually match the inline string before deleting; if a property is missing from the class, add it to the class rather than keeping the inline style.
|
||||
|
||||
- [ ] **Step 4: Delete the now-dead CSS**
|
||||
|
||||
In `styles.css` remove the rules made dead by Steps 1-2: `.toggle-switch` (209-214), `.toggle-switch input` (216-220), `.toggle-slider` (222-232), `.toggle-slider:before` (234-244), `input:checked + .toggle-slider` (246-248), `input:checked + .toggle-slider:before` (250-252), `.tubesage-process-btn` (288-295). Keep `.tubesage-process-btn-mobile` (297-299) only if the mobile class is still attached in Step 2; otherwise remove it too. Keep `.toggle-container`, `.toggle-label`, `.summary-info`.
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: completes with no TypeScript errors and writes `main.js`.
|
||||
Manual check: confirm no remaining references to `toggle-slider`, `toggle-switch`, or `tubesage-process-btn` (without `-mobile`) exist: `grep -n "toggle-slider\|toggle-switch\|tubesage-process-btn[^-]" main.ts styles.css` should return nothing.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "refactor(ui): use native Obsidian toggle and CTA button in create-note modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Settings tab native components
|
||||
|
||||
The settings tab (`YouTubeTranscriptSettingTab`, `main.ts:3759`) hand-rolls a second toggle switch (the license-acceptance toggle, `main.ts:3895-3951`) and uses two inline-style spacer divs.
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `YouTubeTranscriptSettingTab.display()` approx `main.ts:3771-3951`
|
||||
- Modify: `styles.css` — license-toggle rules, spacer
|
||||
|
||||
- [ ] **Step 1: Replace the custom license toggle with a native Obsidian toggle**
|
||||
|
||||
`main.ts:3895-3951` builds `tubesage-license-toggle-wrapper` + `tubesage-license-toggle-input` + `tubesage-license-toggle-slider` + `tubesage-license-toggle-knob`, with manual click handlers on the slider and the label span. Replace the whole block with an Obsidian `ToggleComponent`:
|
||||
- `const licenseToggle = new ToggleComponent(toggleContainer);`
|
||||
- `licenseToggle.setValue(this.plugin.settings.licenseAccepted);`
|
||||
- `licenseToggle.onChange(value => { this.plugin.settings.licenseAccepted = value; void this.plugin.saveSettings().then(updateSettingsState); });`
|
||||
- The native component is already clickable, so delete the manual `toggleSlider` click handler and the `licenseTextElement` click handler (`main.ts:3924-3951`).
|
||||
- `updateSettingsState` (`main.ts:4015-4023`) and the `toggleInput.addEventListener('change', ...)` block (`main.ts:4029-4032`) are replaced by the `onChange` above. Make sure `updateSettingsState()` is still called once for the initial state (`main.ts:4026`).
|
||||
- Keep the `Accept license & disclaimer` label span; it can stay as plain text next to the native toggle.
|
||||
|
||||
- [ ] **Step 2: Remove the inline-style spacer divs**
|
||||
|
||||
`main.ts:3796` and `main.ts:3804` create `<div style="height:10px">` spacers around the Buy Me a Coffee button. Delete both `createDiv({ attr: { style: 'height:10px;' } })` calls and instead give `.tubesage-settings-bmc-container` symmetric vertical margin in `styles.css` (`margin: var(--size-4-3) 0;`). Remove the existing `margin-bottom: 20px` from that rule so spacing is not doubled.
|
||||
|
||||
- [ ] **Step 3: Delete the now-dead CSS**
|
||||
|
||||
In `styles.css` remove the license-toggle rules made dead by Step 1: `.tubesage-license-toggle-wrapper` (369-374), `.tubesage-license-toggle-input` (376-380), `.tubesage-license-toggle-slider` (382-392), `.tubesage-license-toggle-knob` (394-404), and the two `:checked` rules (406-412).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: completes with no TypeScript errors.
|
||||
Manual check: `grep -n "license-toggle" main.ts styles.css` returns nothing.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "refactor(ui): use native Obsidian toggle for license acceptance in settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: styles.css design-system sweep
|
||||
|
||||
With both custom toggles gone, sweep what remains of `styles.css` (and the three remaining inline styles in `main.ts`) onto Obsidian's variable scale.
|
||||
|
||||
**Files:**
|
||||
- Modify: `styles.css` — whole file
|
||||
- Modify: `main.ts:6483`, `main.ts:6489`, `main.ts:6619` — remaining inline styles
|
||||
|
||||
- [ ] **Step 1: Fix the hardcoded heading color**
|
||||
|
||||
`styles.css:895` sets `color: #007acc` on `.setting-item-heading.tubesage-heading .setting-item-name`. Replace with `var(--text-accent)` so section headings follow the active theme. Leave the other heading properties (`font-size`, `font-weight`, layout) as is.
|
||||
|
||||
- [ ] **Step 2: Replace the hand-rolled shadow**
|
||||
|
||||
`styles.css:353` (`.tubesage-settings-info-icon-with-tooltip::after`) uses `box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2)`. Replace with `box-shadow: var(--shadow-s)`.
|
||||
|
||||
- [ ] **Step 3: Normalize border-radius**
|
||||
|
||||
Across `styles.css`, replace the ad-hoc radius mix with theme variables: `border-radius: 4px` and `5px` on inputs, buttons, and small controls become `var(--radius-s)`; `6px` and `8px` on panels and containers (`.tubesage-custom-model-params`, `.tubesage-license-required-steps-container`) become `var(--radius-m)`. Do not change `border-radius: 50%` (circular) or pill radii that belonged to controls deleted in Tasks 1-2 (those rules are already gone).
|
||||
|
||||
- [ ] **Step 4: Put spacing on the Obsidian scale**
|
||||
|
||||
Across `styles.css`, replace ad-hoc pixel padding/margin/gap with the `--size-4-*` scale per the design rules above (5px→`--size-4-1`, 8px→`--size-4-2`, 10px→`--size-4-2`, 12px→`--size-4-3`, 15px→`--size-4-4`, 20px→`--size-4-5`, 30px→`--size-4-8`). Where two adjacent values are within one step of each other and serve the same role, collapse them to one step so the rhythm is regular. Leave fixed structural dimensions alone (modal `width`, `max-height`, `.video-count-dropdown` `width: 60px`, image dimensions, the `1px` borders).
|
||||
|
||||
- [ ] **Step 5: Remove the three remaining inline styles in main.ts**
|
||||
|
||||
- `main.ts:6483`: a `height:1px` divider with `background` and `margin`. Replace the `attr: { style: ... }` with a class `tubesage-divider` and add to `styles.css`: `.tubesage-divider { height: 1px; background: var(--background-modifier-border); margin: var(--size-4-3) 0; }`.
|
||||
- `main.ts:6489` and `main.ts:6619`: both `display:flex; justify-content:flex-end; width:100%`. Add one class `tubesage-row-end { display: flex; justify-content: flex-end; width: 100%; }` to `styles.css` and use it for both.
|
||||
|
||||
- [ ] **Step 6: Build and verify**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: completes with no TypeScript errors.
|
||||
Manual check: `grep -rn "attr: { style" main.ts` returns nothing (all inline styles removed across all three tasks). `grep -n "#007acc\|rgba(0, 0, 0\|: white\b" styles.css` returns nothing.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "refactor(ui): align styles.css to Obsidian theme variables and spacing scale"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npm run build` is green.
|
||||
- [ ] `grep -rn "attr: { style" main.ts` returns nothing.
|
||||
- [ ] No `#hex` colors, `rgba()`, or literal `white`/`black` remain in `styles.css` (except inside the base64 image data, which is not CSS).
|
||||
- [ ] Load the plugin in the dev vault, open the create-note modal and the settings tab, toggle both switches, confirm state persists and the disabled-settings overlay still works.
|
||||
- [ ] Diff review: confirm no logic, settings keys, copy, or event behavior changed.
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
# Reduce Vault-Enumeration and Clipboard Surface Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove every whole-vault enumeration call and the only `navigator.clipboard` call from the TubeSage plugin, so the Obsidian scorecard's "Vault Enumeration" and "Clipboard Access" flags clear, without changing user-facing behavior.
|
||||
|
||||
**Architecture:** Add one scoped folder-walk helper (`collectUnder`) to `src/utils/path-utils.ts`. Rewrite the template picker and folder picker to walk only the relevant folder subtree via that helper instead of `getMarkdownFiles()` / `getAllLoadedFiles()`. Delete a diagnostic-only enumeration block. Remove the "Copy template" button (the only clipboard user).
|
||||
|
||||
**Tech Stack:** TypeScript, Obsidian Plugin API (`Vault`, `TFolder`, `TFile`), esbuild. Verification is `npm run build` and `npm run lint`; there is no unit-test framework, so the build, the lint, and targeted `grep` checks are the tests.
|
||||
|
||||
**Design reference:** `docs/superpowers/specs/2026-05-17-reduce-vault-clipboard-surface-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the `collectUnder` folder-walk helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/path-utils.ts`
|
||||
|
||||
- [ ] **Step 1: Check the existing obsidian import**
|
||||
|
||||
Open `src/utils/path-utils.ts`. Note whether it already imports from `"obsidian"`. The helper needs `Vault`, `TFolder`, and `TFile`. Add an import line if none exists, or extend the existing one:
|
||||
|
||||
```ts
|
||||
import { Vault, TFolder, TFile } from "obsidian";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `collectUnder` helper**
|
||||
|
||||
Append this to `src/utils/path-utils.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Collect descendants of a single folder by walking `TFolder.children`,
|
||||
* without enumerating the whole vault.
|
||||
*
|
||||
* - `kind: "folder"` returns the start folder plus all descendant folders.
|
||||
* - `kind: "markdown"` returns all descendant Markdown (`.md`) files.
|
||||
*
|
||||
* Returns an empty array when `folderPath` does not resolve to a folder.
|
||||
*/
|
||||
export function collectUnder(vault: Vault, folderPath: string, kind: "folder"): TFolder[];
|
||||
export function collectUnder(vault: Vault, folderPath: string, kind: "markdown"): TFile[];
|
||||
export function collectUnder(
|
||||
vault: Vault,
|
||||
folderPath: string,
|
||||
kind: "folder" | "markdown",
|
||||
): Array<TFolder | TFile> {
|
||||
const start = vault.getAbstractFileByPath(folderPath);
|
||||
if (!(start instanceof TFolder)) return [];
|
||||
|
||||
const out: Array<TFolder | TFile> = [];
|
||||
const stack: TFolder[] = [start];
|
||||
while (stack.length > 0) {
|
||||
const folder = stack.pop();
|
||||
if (!folder) break;
|
||||
if (kind === "folder") out.push(folder);
|
||||
for (const child of folder.children) {
|
||||
if (child instanceof TFolder) {
|
||||
stack.push(child);
|
||||
} else if (kind === "markdown" && child instanceof TFile && child.extension === "md") {
|
||||
out.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: completes with no TypeScript errors.
|
||||
Run: `npm run lint`
|
||||
Expected: no new errors or warnings.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/path-utils.ts
|
||||
git commit -m "feat: add collectUnder folder-walk helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Template picker uses `collectUnder` instead of `getMarkdownFiles()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — template picker `onOpen()`, around lines 5318-5345
|
||||
|
||||
- [ ] **Step 1: Add `collectUnder` to the path-utils import**
|
||||
|
||||
`main.ts` line 7 imports from `./src/utils/path-utils`. Add `collectUnder` to that import list. It currently reads:
|
||||
|
||||
```ts
|
||||
import { normalizePath, ensureFolder, joinPaths, sanitizePathComponent } from './src/utils/path-utils';
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```ts
|
||||
import { normalizePath, ensureFolder, joinPaths, sanitizePathComponent, collectUnder } from './src/utils/path-utils';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole-vault enumeration + filter**
|
||||
|
||||
In the template picker `onOpen()`, replace this block (currently around lines 5318-5345):
|
||||
|
||||
```ts
|
||||
// Get all markdown files in the vault
|
||||
// @ts-ignore - Using Obsidian API types
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
logger.debug("Total markdown files in vault:", allFiles.length);
|
||||
|
||||
// Filter to only include files from the templates folder
|
||||
this.templates = allFiles
|
||||
// @ts-ignore - Using Obsidian API types
|
||||
.filter(file => {
|
||||
// Check if the file path starts with the templates folder
|
||||
// or if it's in a subfolder of the templates folder
|
||||
const path = file.path.toLowerCase();
|
||||
const templatesFolder = this.templatesFolder.toLowerCase();
|
||||
|
||||
const isTemplate = path.startsWith(templatesFolder + '/') ||
|
||||
path === templatesFolder ||
|
||||
path.includes('/' + templatesFolder + '/');
|
||||
|
||||
if (isTemplate) {
|
||||
logger.debug("Found template file:", file.path);
|
||||
}
|
||||
|
||||
return isTemplate;
|
||||
})
|
||||
// @ts-ignore - Using Obsidian API types
|
||||
.map(file => ({ path: file.path }));
|
||||
|
||||
logger.debug(`Found ${this.templates.length} template files`);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// Collect template files by walking only the configured templates
|
||||
// folder subtree — no whole-vault enumeration.
|
||||
this.templates = collectUnder(this.app.vault, this.templatesFolder, 'markdown')
|
||||
.map(file => ({ path: file.path }));
|
||||
logger.debug(`Found ${this.templates.length} template files in "${this.templatesFolder}"`);
|
||||
```
|
||||
|
||||
Leave the rest of `onOpen()` (the "no templates found" message, the search input, the list container) unchanged.
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: no TypeScript errors. If `this.templates`'s declared type rejects `{ path: string }[]`, it already accepted it before this change (the old `.map` produced the same shape), so no type change is needed.
|
||||
Run: `npm run lint`
|
||||
Expected: no new errors or warnings. Note the two `@ts-ignore` comments in the old block are removed with it; that is intended.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor: template picker walks the templates folder subtree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Folder picker uses `collectUnder` instead of `getAllLoadedFiles()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — folder picker `loadFolders()`, around lines 5455-5493
|
||||
|
||||
- [ ] **Step 1: Replace the whole-vault enumeration loop**
|
||||
|
||||
In `loadFolders()`, replace this block (currently around lines 5455-5493):
|
||||
|
||||
```ts
|
||||
// Cross-platform implementation: get all files using Obsidian API
|
||||
// This works on both desktop and mobile
|
||||
const files = this.app.vault.getAllLoadedFiles();
|
||||
|
||||
// Add root folder first
|
||||
this.folders.push({
|
||||
path: normalizedRootFolder,
|
||||
name: rootFolder
|
||||
});
|
||||
uniquePaths.add(normalizedRootFolder);
|
||||
|
||||
// Collection of folders for summarized logging
|
||||
const foundFolders: string[] = [];
|
||||
|
||||
// Process all folders from the vault
|
||||
for (const file of files) {
|
||||
// Check if it's a folder by testing its instance type
|
||||
// This approach works on both desktop and mobile
|
||||
if (file && 'children' in file) {
|
||||
const path = file.path || '';
|
||||
|
||||
// Add all folders that are inside the root folder
|
||||
if (path !== rootFolder && (
|
||||
path.startsWith(rootFolder + '/') ||
|
||||
path.startsWith(normalizedRootFolder + '/'))) {
|
||||
|
||||
const normalizedPath = normalizePath(path, false); // Keep leading slash for display
|
||||
if (!uniquePaths.has(normalizedPath)) {
|
||||
this.folders.push({
|
||||
path: normalizedPath,
|
||||
name: path
|
||||
});
|
||||
uniquePaths.add(normalizedPath);
|
||||
// Add to our collection for logging
|
||||
foundFolders.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// Add root folder first — explicit, so the picker always has it
|
||||
// even if the subtree walk below returns nothing.
|
||||
this.folders.push({
|
||||
path: normalizedRootFolder,
|
||||
name: rootFolder
|
||||
});
|
||||
uniquePaths.add(normalizedRootFolder);
|
||||
|
||||
// Collection of folders for summarized logging
|
||||
const foundFolders: string[] = [];
|
||||
|
||||
// Walk only the configured root-folder subtree — no whole-vault
|
||||
// enumeration. collectUnder includes the root folder itself, which
|
||||
// is skipped here since it was already added above.
|
||||
for (const folder of collectUnder(this.app.vault, rootFolder, 'folder')) {
|
||||
const path = folder.path;
|
||||
if (path === rootFolder) continue;
|
||||
const normalizedPath = normalizePath(path, false); // Keep leading slash for display
|
||||
if (!uniquePaths.has(normalizedPath)) {
|
||||
this.folders.push({
|
||||
path: normalizedPath,
|
||||
name: path
|
||||
});
|
||||
uniquePaths.add(normalizedPath);
|
||||
foundFolders.push(path);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave the folder sort and the summary logging that follow (around lines 5495 onward) unchanged. `collectUnder` is already imported into `main.ts` by Task 2.
|
||||
|
||||
- [ ] **Step 2: Build and lint**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: no TypeScript errors.
|
||||
Run: `npm run lint`
|
||||
Expected: no new errors or warnings.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor: folder picker walks the root folder subtree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Delete the diagnostic enumeration block
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — note-not-found error branch, around lines 1701-1725
|
||||
|
||||
- [ ] **Step 1: Remove the diagnostic block**
|
||||
|
||||
In the `if (!(file instanceof TFile))` branch, replace this block (currently around lines 1701-1725):
|
||||
|
||||
```ts
|
||||
logger.error(`Could not find note file: ${filePath}`);
|
||||
// Try to check if any similar files exist
|
||||
const folder = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
try {
|
||||
// @ts-ignore - Using internal Obsidian API
|
||||
const folderContents = (this.app.vault.getMarkdownFiles() as Array<{ path: string }>)
|
||||
.filter((f) => f.path.startsWith(folder))
|
||||
.map((f) => f.path);
|
||||
if (folderContents.length > 0) {
|
||||
// Only show a limited number of files to avoid excessive logging
|
||||
const MAX_FILES_TO_LOG = 3;
|
||||
if (folderContents.length <= MAX_FILES_TO_LOG) {
|
||||
logger.debug(`Files in the same folder: ${folderContents.join(', ')}`);
|
||||
} else {
|
||||
const shownFiles = folderContents.slice(0, MAX_FILES_TO_LOG);
|
||||
logger.debug(`Files in the same folder (${folderContents.length} total): ${shownFiles.join(', ')}... and ${folderContents.length - MAX_FILES_TO_LOG} more`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`No files found in folder: ${folder}`);
|
||||
}
|
||||
} catch (folderError) {
|
||||
const errorMessage = getSafeErrorMessage(folderError);
|
||||
logger.error(`Error checking folder contents: ${errorMessage}`);
|
||||
}
|
||||
throw new Error('Could not find note file');
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
logger.error(`Could not find note file: ${filePath}`);
|
||||
throw new Error('Could not find note file');
|
||||
```
|
||||
|
||||
This removes the only purpose of the `getMarkdownFiles()` call here (debug logging of nearby files). The error log and the thrown error are preserved.
|
||||
|
||||
- [ ] **Step 2: Build and lint**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: no TypeScript errors. If `getSafeErrorMessage` becomes an unused import, the build (or lint) will flag it — only remove the import if a build/lint error explicitly says it is now unused; it is used elsewhere in `main.ts`, so expect no change.
|
||||
Run: `npm run lint`
|
||||
Expected: no new errors or warnings.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor: drop diagnostic vault enumeration in note-not-found branch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove the "Copy template" button
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — template-viewer modal, around lines 6398-6481
|
||||
|
||||
- [ ] **Step 1: Remove the copy-button block**
|
||||
|
||||
In the template-viewer modal, delete the entire block that builds the copy button — from the `// Create a container for the copy button` comment through the closing brace of the `if (copyTextElement)` block. Currently around lines 6398-6481, it starts with:
|
||||
|
||||
```ts
|
||||
// Create a container for the copy button
|
||||
const copyContainer = contentEl.createDiv({
|
||||
cls: ['tubesage-template-view-copy-container', 'tubesage-row-end']
|
||||
});
|
||||
```
|
||||
|
||||
and ends with:
|
||||
|
||||
```ts
|
||||
copyButton.addEventListener('click', handleCopy);
|
||||
if (copyTextElement) {
|
||||
copyTextElement.addEventListener('click', handleCopy);
|
||||
}
|
||||
```
|
||||
|
||||
Delete everything from the `// Create a container for the copy button` comment line through that final `}` inclusive. This removes `copyContainer`, the "Copy template" span, `copyButton`, the copy SVG, the hover handlers, the `handleCopy` function, and the `navigator.clipboard.writeText` call.
|
||||
|
||||
Keep the line that follows — `templateContainer.createEl('pre', { ... text: templateContent })` — and everything after it. `templateContent` is still used by that `pre` element. Leave the `tubesage-divider` separator line that precedes the deleted block; it still visually separates the template content from the explanation below.
|
||||
|
||||
- [ ] **Step 2: Build and verify the clipboard call is gone**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: no TypeScript errors. If `svgNamespace` was declared inside the deleted block and is referenced later in the same method, the build will fail with an undefined-name error — in that case, also move/keep the single `const svgNamespace = "http://www.w3.org/2000/svg";` declaration that is still needed. (Expected: no such error; the copy SVG is self-contained.)
|
||||
Run: `npm run lint`
|
||||
Expected: no new errors or warnings.
|
||||
Run: `grep -rn "navigator.clipboard\|clipboard" main.ts src/ --include="*.ts"`
|
||||
Expected: no matches.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor: remove Copy template button (drops navigator.clipboard use)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npm run build` is green.
|
||||
- [ ] `npm run lint` reports 0 errors and 0 warnings.
|
||||
- [ ] `grep -rn "getMarkdownFiles\|getAllLoadedFiles\|getFiles\|navigator.clipboard" main.ts src/ --include="*.ts"` returns nothing.
|
||||
- [ ] `grep -rn "getAbstractFileByPath" main.ts | wc -l` is unchanged or higher (targeted lookups are fine; only enumeration APIs were removed).
|
||||
- [ ] Manual check in a dev vault: open the create-note folder picker — it lists the folders under the configured root folder. Open the template picker — it lists the Markdown files under the configured templates folder. Open the template viewer — the template text shows, with no Copy button.
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
# Copy Button, Info-Tooltips, Diagram Refresh Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore the "Copy template" button, fix the settings info-icon tooltips by switching to Obsidian's native `setTooltip`, and refresh the two mermaid architecture diagrams.
|
||||
|
||||
**Architecture:** Three independent tasks. Task 1 reverts the two commits that removed the copy button and adds a mobile touch-scroll hint. Task 2 replaces the hand-rolled `data-tooltip`/`::after`/`title` tooltip in `createInfoIcon` with Obsidian's native `setTooltip` and deletes the dead CSS. Task 3 refreshes the mermaid docs using graphify output as factual input.
|
||||
|
||||
**Tech Stack:** TypeScript, Obsidian Plugin API (`setTooltip`), esbuild, mermaid. Verification is `npm run build` and `npm run lint`; there is no unit-test framework, so build, lint, and `grep` checks are the tests.
|
||||
|
||||
**Design reference:** `docs/superpowers/specs/2026-05-18-copy-button-tooltips-diagrams-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Restore the "Copy template" button
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` (template-viewer modal — restored by revert)
|
||||
- Modify: `styles.css` (copy-button rules restored by revert; touch-scroll hint added)
|
||||
|
||||
- [ ] **Step 1: Revert the two removal commits**
|
||||
|
||||
Run: `git revert --no-commit d658ae21 3710f47f`
|
||||
|
||||
`d658ae21` removed the copy-button markup from `main.ts`; `3710f47f` removed `.tubesage-template-view-copy-container` and `.tubesage-template-view-copy-text` from `styles.css`. Reverting both restores the copy button, its `handleCopy` function (which calls `navigator.clipboard.writeText(templateContent)` with "Copied" / "Failed to copy" feedback), and the two CSS rules.
|
||||
|
||||
If `git revert` reports a conflict, resolve it by taking the restored copy-button block: inspect each commit with `git show d658ae21` / `git show 3710f47f` and re-apply the removed lines by hand, then `git revert --skip` or clear the revert state. The regions involved (the template-viewer modal in `main.ts`, the two CSS rules in `styles.css`) were not modified after those commits, so a clean revert is expected.
|
||||
|
||||
- [ ] **Step 2: Add a mobile touch-scroll hint to the scroll containers**
|
||||
|
||||
In `styles.css`, the three scrollable content boxes — `.tubesage-template-view-container`, `.tubesage-license-container`, `.tubesage-readme-container` — each already have `overflow-y: auto`. Add this one declaration to each of the three rules:
|
||||
|
||||
```css
|
||||
-webkit-overflow-scrolling: touch;
|
||||
```
|
||||
|
||||
This is the standard momentum-scroll enabler for the mobile webview. It is harmless on desktop. (If mobile scrolling is still broken after this, that is a deeper investigation out of scope for this task — the copy button is the guaranteed path.)
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: completes with no TypeScript errors. The restored `handleCopy` uses `window.setTimeout` (already lint-clean) and `navigator.clipboard.writeText`.
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
Step 1 used `git revert --no-commit`, so the revert changes are staged but not yet committed. Commit them together with the Step 2 CSS edit as one commit:
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "feat: restore Copy template button, add mobile touch-scroll hint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Native tooltips for the settings info icons
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `createInfoIcon` (~line 4971-5018), the obsidian import (line 1), the LLM heading tooltip text (~line 4170)
|
||||
- Modify: `styles.css` — remove dead tooltip rules (~lines 282-314)
|
||||
|
||||
- [ ] **Step 1: Add `setTooltip` to the obsidian import**
|
||||
|
||||
`main.ts` line 1 imports a list of names from `'obsidian'`. Add `setTooltip` to that import list (it is a documented Obsidian API function: `setTooltip(el, tooltip, options?)`).
|
||||
|
||||
- [ ] **Step 2: Replace the custom tooltip in `createInfoIcon`**
|
||||
|
||||
In `createInfoIcon` (around `main.ts:5010-5016`), find this block:
|
||||
|
||||
```ts
|
||||
// Add tooltip using pure CSS approach
|
||||
infoIcon.setAttribute('data-tooltip', tooltipText);
|
||||
infoIcon.addClass('tubesage-settings-info-icon-with-tooltip');
|
||||
|
||||
// Provide native tooltip fallback
|
||||
infoIcon.setAttr('title', tooltipText);
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
// Native Obsidian tooltip — renders reliably, positioned correctly,
|
||||
// and works on mobile (unlike the prior hover-only CSS tooltip).
|
||||
setTooltip(infoIcon, tooltipText, { placement: 'bottom' });
|
||||
```
|
||||
|
||||
Leave the rest of `createInfoIcon` (the span creation, the SVG icon, `return infoIcon`) unchanged. The `aria-label` attribute and the `.tubesage-settings-info-icon` class stay.
|
||||
|
||||
- [ ] **Step 3: Append the OpenRouter reliability note to the LLM tooltip**
|
||||
|
||||
Around `main.ts:4170`, the LLM heading `createInfoIcon` call passes this tooltip string:
|
||||
|
||||
```
|
||||
Choose an AI provider, enter its API key, and pick a model. Temperature controls creativity; max tokens caps output length. Suggested for most users: Google provider with the gemini-2.5-flash model — fast, inexpensive, and high-quality.
|
||||
```
|
||||
|
||||
Append one sentence to the end of that string (inside the quotes), so it reads:
|
||||
|
||||
```
|
||||
... fast, inexpensive, and high-quality. If you hit rate limits or reliability issues, OpenRouter is a solid fallback.
|
||||
```
|
||||
|
||||
Change only the string content; do not change the `createInfoIcon` call structure.
|
||||
|
||||
- [ ] **Step 4: Delete the dead tooltip CSS**
|
||||
|
||||
In `styles.css`, remove these three now-unused rules (around lines 282-314):
|
||||
- `.tubesage-settings-info-icon-with-tooltip` (the `position: relative` rule)
|
||||
- `.tubesage-settings-info-icon-with-tooltip::after` (the `content: attr(data-tooltip)` tooltip box)
|
||||
- `.tubesage-settings-info-icon-with-tooltip:hover::after`
|
||||
|
||||
Keep `.tubesage-settings-info-icon` (the icon span styling, including `cursor: help`).
|
||||
|
||||
- [ ] **Step 5: Build, lint, and verify the old mechanism is gone**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: no TypeScript errors.
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors, 0 warnings.
|
||||
Run: `grep -rn "data-tooltip\|info-icon-with-tooltip" main.ts styles.css`
|
||||
Expected: no matches.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "fix: use native setTooltip for settings info icons, add OpenRouter note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Refresh the mermaid architecture diagrams
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/workflow-diagram.md`
|
||||
- Modify: `docs/data-flow-diagram.md`
|
||||
|
||||
- [ ] **Step 1: Generate current architecture facts with graphify**
|
||||
|
||||
From the repo root, run `graphify . --update` to refresh the knowledge graph (AST-only, no API cost), then read `graphify-out/GRAPH_REPORT.md` for the current module/community structure. Use it as factual input — do not invent architecture.
|
||||
|
||||
- [ ] **Step 2: Read the current diagrams and the codebase entry points**
|
||||
|
||||
Read `docs/workflow-diagram.md` and `docs/data-flow-diagram.md` in full. Note the existing mermaid `%%{init: ...}%%` theme block and the surrounding prose — these are kept. Cross-check against `main.ts` (the orchestrator), `src/llm/` (provider clients), `src/youtube-transcript.ts`, `src/utils/fetch-shim.ts`, and `manifest.json`.
|
||||
|
||||
- [ ] **Step 3: Update `workflow-diagram.md`**
|
||||
|
||||
Update the mermaid flowchart's nodes/edges so the user-and-system workflow reflects the current architecture. It must accurately show:
|
||||
- API keys read from / written to Obsidian secret storage for cloud providers (OpenAI, Anthropic, Google, OpenRouter); the Ollama server URL stays in plugin data.
|
||||
- Native Obsidian UI components (modal, settings toggles, CTA button).
|
||||
- Folder/template selection via scoped subtree traversal (`collectUnder`), not whole-vault enumeration.
|
||||
- Providers: OpenAI, Anthropic, Google, OpenRouter via LangChain; Ollama local.
|
||||
Keep the `%%{init}` theme block and the document's prose headings. Change only diagram content. Do not remove sections that are still accurate.
|
||||
|
||||
- [ ] **Step 4: Update `data-flow-diagram.md`**
|
||||
|
||||
Update the mermaid flowchart so the data flow reflects the current architecture. It must accurately show:
|
||||
- Cloud API keys flowing through Obsidian secret storage, never `data.json`.
|
||||
- The transcript → LLM summarization → template → note pipeline.
|
||||
- The cross-platform `obsidianFetch` shim for HTTP.
|
||||
- LangChain's tiktoken helper stubbed at build time (no `tiktoken.pages.dev` request).
|
||||
Keep the `%%{init}` theme block and prose. Change only diagram content.
|
||||
|
||||
- [ ] **Step 5: Verify mermaid validity**
|
||||
|
||||
Confirm both files' mermaid blocks are syntactically valid (balanced brackets, valid node/edge syntax, every node referenced is defined). There is no mermaid CLI in the project; review by reading. `npm run build` does not cover docs, so just confirm the markdown is well-formed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/workflow-diagram.md docs/data-flow-diagram.md
|
||||
git commit -m "docs: refresh workflow and data-flow mermaid diagrams"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npm run build` is green.
|
||||
- [ ] `npm run lint` reports 0 errors and 0 warnings.
|
||||
- [ ] `grep -rn "data-tooltip\|info-icon-with-tooltip" main.ts styles.css` returns nothing.
|
||||
- [ ] `grep -n "navigator.clipboard" main.ts` shows the restored copy-button call (the clipboard surface is intentionally back).
|
||||
- [ ] The LLM info tooltip text contains the OpenRouter sentence.
|
||||
- [ ] Manual in a dev vault: the template viewer shows the Copy button and it copies; the three settings info icons (Transcript, LLM, Advanced headings) show their tooltip promptly on hover; both mermaid diagrams render.
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
# Mobile Template-Viewer Height Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** On Obsidian mobile, make the example-template viewer modal large enough that a real portion of the template is visible and scrollable, instead of collapsing to a blank single line.
|
||||
|
||||
**Architecture:** CSS-only. Two rules are added to the existing `@media (max-width: 768px)` block in `styles.css`: a mobile size override for the template-viewer modal, and a definite height for its content container. No TypeScript change, no behavior change, desktop untouched.
|
||||
|
||||
**Tech Stack:** CSS, Obsidian theme variables. The plugin is bundled with esbuild; `styles.css` is shipped verbatim (not bundled), so no rebuild is strictly required for the CSS to take effect, but a build is run as a regression check.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`.tubesage-template-view-container` (`styles.css:648`) sets only `max-height` — 500px, narrowed to 250px when the modal also applies the `tubesage-template-view-container-short` modifier (`styles.css:661`). `max-height` only caps height; the box's rendered height is the content's intrinsic height. On desktop the content establishes a normal height so the box fills out. On mobile the box collapses to roughly one blank line, so the template appears missing.
|
||||
|
||||
Separately, `.tubesage-template-view-modal-size.modal` (`styles.css:638`) has no entry in the `@media (max-width: 768px)` block, unlike the license and readme modals which get explicit mobile size overrides there (`styles.css:943-948`).
|
||||
|
||||
The fix: add both rules inside the existing mobile media query, immediately after the `.tubesage-license-required-modal-size.modal` rule and before the closing `}` of the media block (currently `styles.css:955`).
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `styles.css` — add two CSS rules inside the existing `@media (max-width: 768px)` block. This is the only file changed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add mobile sizing for the template-viewer modal and container
|
||||
|
||||
**Files:**
|
||||
- Modify: `styles.css:950-955` (insert new rules after the `.tubesage-license-required-modal-size.modal` block, before the media query's closing brace)
|
||||
|
||||
This project has no unit-test framework and CSS is not unit-testable. Verification is a clean build plus a manual visual check; there is no failing-test step.
|
||||
|
||||
- [ ] **Step 1: Add the two CSS rules**
|
||||
|
||||
In `styles.css`, find this block (it ends the `@media (max-width: 768px)` section):
|
||||
|
||||
```css
|
||||
.tubesage-license-required-modal-size.modal {
|
||||
width: 90vw;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```css
|
||||
.tubesage-license-required-modal-size.modal {
|
||||
width: 90vw;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
/* Template viewer: give the modal a mobile size and the content a
|
||||
definite height so a usable portion of the template shows and scrolls
|
||||
(max-height alone lets the box collapse to a blank line on mobile). */
|
||||
.tubesage-template-view-modal-size.modal {
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.tubesage-template-view-container {
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `.tubesage-template-view-container` rule deliberately re-states `max-height` so it overrides both the desktop `max-height: 500px` (`styles.css:649`) and the `tubesage-template-view-container-short` modifier's `max-height: 250px` (`styles.css:662`). The `height: 50vh` forces a definite height; the existing `overflow-y: auto` and `-webkit-overflow-scrolling: touch` on the base rule make the rest scrollable.
|
||||
|
||||
- [ ] **Step 2: Verify the build is clean**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: exits 0, no TypeScript errors. (`styles.css` is not processed by esbuild; this confirms nothing else broke.)
|
||||
|
||||
- [ ] **Step 3: Verify lint is clean**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: `0 errors, 0 warnings`. (ESLint does not lint CSS here; this confirms the repo still settles clean.)
|
||||
|
||||
- [ ] **Step 4: Manual visual check (desktop + narrow window)**
|
||||
|
||||
In a dev vault with the plugin loaded, open the example-template viewer modal.
|
||||
- Desktop / wide window: modal is unchanged (700px wide, content area caps at its existing height).
|
||||
- Narrow the window below 768px (or use Obsidian mobile): the modal is ~95vw wide and the template content area is ~50vh tall, showing a real portion of the template with scrolling for the rest — not a blank line.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add styles.css
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix: enlarge mobile example-template viewer so content is visible
|
||||
|
||||
The template-viewer content box used only max-height, so on mobile it
|
||||
collapsed to a blank single line. Add a mobile size override for the
|
||||
modal and a definite height for the content container inside the
|
||||
existing max-width:768px media query. Desktop is unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Spec "Change" item 1 (`.tubesage-template-view-modal-size.modal` → 95vw/95vw/85vh) → Task 1 Step 1. ✓
|
||||
- Spec "Change" item 2 (`.tubesage-template-view-container` → height/max-height 50vh) → Task 1 Step 1. ✓
|
||||
- Spec "CSS only, no TypeScript change, desktop untouched" → only `styles.css` is modified, new rules live inside the mobile media query. ✓
|
||||
- Spec "Testing" (`npm run build` clean, manual mobile check) → Task 1 Steps 2 and 4. ✓
|
||||
- No spec requirement is unaddressed.
|
||||
|
||||
**2. Placeholder scan:** No TBD/TODO/vague steps. Every code step shows the exact before/after CSS. ✓
|
||||
|
||||
**3. Type consistency:** No types or function signatures involved (CSS-only). Selector names (`.tubesage-template-view-modal-size.modal`, `.tubesage-template-view-container`) match the existing rules in `styles.css` exactly. ✓
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
# Service Info Icons, OpenRouter Refresh, Desktop Spinner Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add service-URL info icons to three settings, add an OpenRouter model-list refresh, and stop the desktop processing spinner from leaving a blank popup.
|
||||
|
||||
**Architecture:** Three independent tasks in `main.ts`. Task 1 attaches `createInfoIcon` to the ScrapeCreators/Supadata/OpenRouter settings. Task 2 adds a `fetchOpenRouterModels` method modelled on `fetchGoogleModels`, wires it into the refresh button, and vendor-groups the OpenRouter model dropdown. Task 3 makes the processing modal mobile-only so desktop shows just the status-bar spinner.
|
||||
|
||||
**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild, `obsidianFetch` shim. Verification is `npm run build` and `npm run lint`; there is no unit-test framework, so build, lint, and `grep` are the tests.
|
||||
|
||||
**Design reference:** `docs/superpowers/specs/2026-05-18-service-info-openrouter-desktop-spinner-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Service-URL info icons
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — the ScrapeCreators setting (~3975), the Supadata setting (~4003), and `createProviderApiKeyRow`
|
||||
|
||||
The plugin's `createInfoIcon(container, tooltipText)` method (in `YouTubeTranscriptSettingTab`) creates an ⓘ icon whose hover tooltip is `tooltipText`. The three settings-section headings already use it. Attach it to three more settings, tooltip = the service URL.
|
||||
|
||||
- [ ] **Step 1: ScrapeCreators and Supadata info icons**
|
||||
|
||||
The ScrapeCreators API-key setting is built as `new Setting(settingsContainer).setName('Scrape creators API key')...` (~`main.ts:3975`); the Supadata one as `new Setting(settingsContainer).setName('Supa data API key')...` (~4003). Each is currently a bare `new Setting(...)` expression not assigned to a variable.
|
||||
|
||||
For each, assign it to a `const` and after the chain attach the info icon by querying its name element — the same way the heading info icons do it:
|
||||
|
||||
```ts
|
||||
const scSetting = new Setting(settingsContainer)
|
||||
.setName('Scrape creators API key')
|
||||
.setDesc(/* unchanged */)
|
||||
.addText(/* unchanged */);
|
||||
const scNameEl = scSetting.settingEl.querySelector('.setting-item-name');
|
||||
if (scNameEl && scNameEl.instanceOf(HTMLElement)) {
|
||||
this.createInfoIcon(scNameEl, 'https://scrapecreators.com/');
|
||||
}
|
||||
```
|
||||
|
||||
Do the same for the Supadata setting with `'https://supadata.ai/'`. Leave the `.setName`/`.setDesc`/`.addText` content of both settings exactly as is.
|
||||
|
||||
- [ ] **Step 2: OpenRouter info icon**
|
||||
|
||||
The OpenRouter API-key row is produced by the helper `createProviderApiKeyRow(provider, displayName, placeholder)` (in `YouTubeTranscriptSettingTab`), which builds a `new Setting(...)` for every provider. Read that helper. After it builds its `Setting`, add a provider-gated info icon: when `provider === 'openrouter'`, query the setting's `.setting-item-name` element and call `this.createInfoIcon(nameEl, 'https://openrouter.ai/')`. Guard the element with `instanceOf(HTMLElement)`, exactly as in Step 1. Only `openrouter` gets the icon; the other providers' rows are unchanged.
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build` — no TypeScript errors.
|
||||
Run: `npm run lint` — 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat(settings): add service-URL info icons for ScrapeCreators, Supadata, OpenRouter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: OpenRouter model refresh
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — add `fetchOpenRouterModels` near `fetchGoogleModels` (~2581); the refresh button in `createProviderConfigBlock` (~4264-4323); the model dropdown population in `createProviderConfigBlock` (~4207-4212)
|
||||
|
||||
- [ ] **Step 1: Add the `fetchOpenRouterModels` method**
|
||||
|
||||
Add this method to the `YouTubeTranscriptPlugin` class, next to `fetchGoogleModels` / `fetchAnthropicModels`. It mirrors `fetchGoogleModels` but hits OpenRouter's public models endpoint (no API key) and parses OpenRouter's response shape:
|
||||
|
||||
```ts
|
||||
async fetchOpenRouterModels(): Promise<FetchedModelInfo[]> {
|
||||
const url = 'https://openrouter.ai/api/v1/models';
|
||||
try {
|
||||
this.showNotice("Fetching OpenRouter models...", 3000);
|
||||
const response = await obsidianFetch(url, { method: 'GET' });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = `HTTP error ${response.status}`;
|
||||
logger.error(`[fetchOpenRouterModels] Failed to fetch OpenRouter models: ${errorMessage}`);
|
||||
this.showNotice(`Failed to fetch OpenRouter models: ${errorMessage}`, 5000);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
data?: Array<{
|
||||
id?: string;
|
||||
context_length?: number;
|
||||
top_provider?: { max_completion_tokens?: number | null };
|
||||
}>;
|
||||
};
|
||||
|
||||
if (data && Array.isArray(data.data)) {
|
||||
const models: FetchedModelInfo[] = data.data
|
||||
.filter((m) => typeof m.id === 'string' && m.id.length > 0)
|
||||
.map((m) => {
|
||||
const id = m.id as string;
|
||||
const contextK = m.context_length ? Math.round(m.context_length / 1000) : undefined;
|
||||
const maxOut = m.top_provider?.max_completion_tokens;
|
||||
const maxOutputK = maxOut ? Math.round(maxOut / 1000) : undefined;
|
||||
return { id, contextK, maxOutputK };
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const m of models) {
|
||||
if (m.contextK && m.maxOutputK) {
|
||||
this.settings.customModelLimits[`openrouter:${m.id}`] = {
|
||||
contextK: m.contextK,
|
||||
maxOutputK: m.maxOutputK,
|
||||
reservePct: 0.10,
|
||||
};
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
if (updatedCount > 0) {
|
||||
logger.info(`[fetchOpenRouterModels] Stored token limits for ${updatedCount} OpenRouter models.`);
|
||||
}
|
||||
|
||||
this.settings.fetchedModels = {
|
||||
...this.settings.fetchedModels,
|
||||
openrouter: models.map((m) => m.id),
|
||||
};
|
||||
await this.saveSettings();
|
||||
|
||||
logger.info(`[fetchOpenRouterModels] Successfully fetched ${models.length} OpenRouter models.`);
|
||||
this.showNotice("OpenRouter models updated!", 3000);
|
||||
return models;
|
||||
} else {
|
||||
logger.warn("[fetchOpenRouterModels] Unexpected response structure from OpenRouter API.");
|
||||
this.showNotice("Could not parse OpenRouter models from API response.", 5000);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getSafeErrorMessage(error);
|
||||
logger.error("[fetchOpenRouterModels] Error fetching or parsing OpenRouter models:", errorMessage);
|
||||
this.showNotice(`Error fetching OpenRouter models: ${errorMessage}`, 5000);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `FetchedModelInfo`, `customModelLimits`, `fetchedModels`, `obsidianFetch`, `getSafeErrorMessage`, and `logger` are the same symbols `fetchGoogleModels` uses (they are — keep the call identical in shape). On a failure path the existing `fetchedModels.openrouter` is left untouched (the method returns `[]` without writing).
|
||||
|
||||
- [ ] **Step 2: Wire the refresh button for OpenRouter**
|
||||
|
||||
In `createProviderConfigBlock` (~`main.ts:4264`), the refresh `ExtraButton` is added only `if (provider === 'openai' || provider === 'google' || provider === 'anthropic')`. Add `|| provider === 'openrouter'` to that condition.
|
||||
|
||||
Inside the button's `onClick`, the current code early-returns when the API key is missing:
|
||||
|
||||
```ts
|
||||
const apiKey = this.plugin.settings.apiKeys[provider];
|
||||
if (!apiKey || apiKey.trim() === '') {
|
||||
this.plugin.showNotice(`${displayName} API key is required to refresh models.`, 5000);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Change the guard so it does NOT apply to OpenRouter (its endpoint is public):
|
||||
|
||||
```ts
|
||||
const apiKey = this.plugin.settings.apiKeys[provider];
|
||||
if (provider !== 'openrouter' && (!apiKey || apiKey.trim() === '')) {
|
||||
this.plugin.showNotice(`${displayName} API key is required to refresh models.`, 5000);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
In the provider `if/else if` chain that picks the fetcher, add an OpenRouter branch (it takes no argument):
|
||||
|
||||
```ts
|
||||
} else if (provider === 'openrouter') {
|
||||
fetchedModels = await this.plugin.fetchOpenRouterModels();
|
||||
}
|
||||
```
|
||||
|
||||
The rest of the `onClick` (selected-model fallback, `getEffectiveMaxTokens`, `saveSettings`, `this.display()`) already works generically and needs no change.
|
||||
|
||||
- [ ] **Step 3: Vendor-group the OpenRouter model dropdown**
|
||||
|
||||
In `createProviderConfigBlock`, the model dropdown is populated (~`main.ts:4207-4212`) with a flat loop:
|
||||
|
||||
```ts
|
||||
mergedOptions.forEach((model) => {
|
||||
dropdown.addOption(model, model);
|
||||
});
|
||||
dropdown.addOption('custom', 'Use custom model');
|
||||
```
|
||||
|
||||
`mergedOptions` is already deduplicated and sorted. For OpenRouter the list is 300+ entries with `vendor/model` ids, so render it grouped by vendor. Replace the population so that, when `provider === 'openrouter'`, options are added under `<optgroup>` headers instead of flat:
|
||||
|
||||
```ts
|
||||
if (provider === 'openrouter') {
|
||||
// Group by vendor (segment before the first '/'); mergedOptions is sorted,
|
||||
// so vendors are already contiguous.
|
||||
let currentVendor = '';
|
||||
let group: HTMLOptGroupElement | null = null;
|
||||
for (const model of mergedOptions) {
|
||||
const vendor = model.includes('/') ? model.slice(0, model.indexOf('/')) : 'other';
|
||||
if (vendor !== currentVendor) {
|
||||
currentVendor = vendor;
|
||||
group = dropdown.selectEl.createEl('optgroup', { attr: { label: vendor } });
|
||||
}
|
||||
(group ?? dropdown.selectEl).createEl('option', { value: model, text: model });
|
||||
}
|
||||
} else {
|
||||
mergedOptions.forEach((model) => {
|
||||
dropdown.addOption(model, model);
|
||||
});
|
||||
}
|
||||
dropdown.addOption('custom', 'Use custom model');
|
||||
```
|
||||
|
||||
`DropdownComponent.addOption` is flat, so the `<optgroup>`/`<option>` elements are created directly on `dropdown.selectEl` for the OpenRouter case. The non-OpenRouter branch is unchanged. The `dropdown.setValue(...)` / `onChange` wiring below this block is unchanged and still works (it operates on the `<select>` value).
|
||||
|
||||
- [ ] **Step 4: Build, lint, verify**
|
||||
|
||||
Run: `npm run build` — no TypeScript errors.
|
||||
Run: `npm run lint` — 0 errors, 0 warnings.
|
||||
Confirm `grep -n "fetchOpenRouterModels" main.ts` shows the method definition and the one call site in the refresh button.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "feat(llm): add OpenRouter model-list refresh with vendor-grouped dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Desktop processing spinner — no blank popup
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `beginCollectionProcessing` (~3120-3140) and the single-video processing method (~3443-3460)
|
||||
|
||||
Both methods currently keep the create-note modal open and reuse it as a processing popup. On desktop the spinner renders to the status bar, so the modal is left blank.
|
||||
|
||||
- [ ] **Step 1: Make the processing modal mobile-only in `beginCollectionProcessing`**
|
||||
|
||||
In `beginCollectionProcessing` (~`main.ts:3120`), the current block is:
|
||||
|
||||
```ts
|
||||
// Show processing UI
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// Adjust modal size to fit the animation using a CSS class
|
||||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||||
modalEl.addClass('tubesage-processing-modal');
|
||||
}
|
||||
|
||||
// Braille-dots processing spinner (status bar on desktop, in-modal on mobile)
|
||||
spinner = new ProcessingSpinner(this.plugin, 'Processing collection', contentEl);
|
||||
spinner.start();
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
// Processing UI. On mobile the modal stays open and hosts the
|
||||
// spinner; on desktop the status bar is the spinner surface, so the
|
||||
// modal is closed — no blank popup. ProcessingSpinner routes itself
|
||||
// (status bar on desktop, in-modal on mobile).
|
||||
const { contentEl } = this;
|
||||
if (Platform.isMobile) {
|
||||
contentEl.empty();
|
||||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||||
modalEl.addClass('tubesage-processing-modal');
|
||||
}
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
|
||||
spinner = new ProcessingSpinner(this.plugin, 'Processing collection', contentEl);
|
||||
spinner.start();
|
||||
```
|
||||
|
||||
`Platform` is already imported. Closing the modal on desktop is safe: `YouTubeTranscriptModal.onClose()` only calls `contentEl.empty()` and does not touch `isProcessing` or abort the async run.
|
||||
|
||||
- [ ] **Step 2: Same change in the single-video processing method**
|
||||
|
||||
The single-video processing method (~`main.ts:3443`) has the identical block, differing only in the spinner label `'Processing video'`. Apply the identical mobile/desktop gating there, keeping its `'Processing video'` label:
|
||||
|
||||
```ts
|
||||
const { contentEl } = this;
|
||||
if (Platform.isMobile) {
|
||||
contentEl.empty();
|
||||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||||
modalEl.addClass('tubesage-processing-modal');
|
||||
}
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
|
||||
spinner = new ProcessingSpinner(this.plugin, 'Processing video', contentEl);
|
||||
spinner.start();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check for desktop use of `contentEl` after processing starts**
|
||||
|
||||
After this change, on desktop the modal is closed when processing begins. Search both methods for any later code that writes to `this.contentEl` or the modal DOM (e.g. rendering a result/status into the modal). If any exists, it must be guarded `if (Platform.isMobile)` too, or moved to a Notice. Report what you find. (Progress is reported via the status-bar spinner and `showNotice` calls, which are platform-independent.)
|
||||
|
||||
- [ ] **Step 4: Build and lint**
|
||||
|
||||
Run: `npm run build` — no TypeScript errors.
|
||||
Run: `npm run lint` — 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "fix(ui): close create-note modal on desktop during processing (no blank popup)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npm run build` is green.
|
||||
- [ ] `npm run lint` reports 0 errors and 0 warnings.
|
||||
- [ ] `grep -n "createInfoIcon" main.ts` shows the three new call sites (ScrapeCreators, Supadata, OpenRouter) plus the three pre-existing heading ones.
|
||||
- [ ] `grep -n "fetchOpenRouterModels" main.ts` shows the method and its refresh-button call site.
|
||||
- [ ] Manual in a dev vault: the ScrapeCreators / Supadata / OpenRouter settings show an ⓘ icon with the service URL on hover; selecting OpenRouter and clicking refresh fetches the full model list, the dropdown is vendor-grouped, and token limits update; on desktop, starting collection and single-video processing closes the modal and shows the status-bar spinner with no blank popup; on mobile the in-modal spinner still works.
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
# Tier A Native-Component Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace hand-rolled UI primitives in TubeSage's modals and settings tab with native Obsidian components (`setIcon`, `ExtraButtonComponent`, `ButtonComponent`, `TextComponent`, `DropdownComponent`) with no intended change to look and feel.
|
||||
|
||||
**Architecture:** Four independent tasks in `main.ts`: the standalone info icon, the three icon buttons, the six text buttons, and the create-note modal's inputs. Each converts hand-built markup to a native component while preserving every existing behavior (click handlers, tooltips, placeholders, validation) and keeping bespoke CSS classes where they carry look-and-feel.
|
||||
|
||||
**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild. Verification is `npm run build` and `npm run lint`; there is no unit-test framework, so build, lint, and `grep` are the tests. The 1.3.3 release is the visual reference — no visible change is the acceptance bar.
|
||||
|
||||
**Design reference:** `docs/superpowers/specs/2026-05-18-tier-a-native-components-design.md`
|
||||
|
||||
**General rule for every task:** preserve behavior exactly. Do not change click handlers, tooltip text, copy, settings keys, or layout. When a native component replaces a raw element that carried a bespoke CSS class, re-apply that class to the component's element (`.buttonEl` / `.extraSettingsEl` / `.inputEl` / `.selectEl`) so the look is unchanged, unless the task says the class is now dead.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Info icon via `setIcon`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `createInfoIcon` (~lines 4971-5018)
|
||||
|
||||
- [ ] **Step 1: Replace the hand-built info SVG with `setIcon`**
|
||||
|
||||
In `createInfoIcon`, the body builds an info SVG with `createElementNS` (the `infoSvg`, `circle`, `line`, `dot` elements and the `infoSvgNamespace` constant, roughly lines 4979-5008). Replace that whole SVG-construction block with one call:
|
||||
|
||||
```ts
|
||||
setIcon(infoIcon, 'info');
|
||||
```
|
||||
|
||||
`setIcon` is a global Obsidian function. Add `setIcon` to the `import { ... } from 'obsidian'` line at the top of `main.ts` if it is not already imported (it is used elsewhere in the file, so it likely already is — verify).
|
||||
|
||||
Keep the rest of `createInfoIcon` unchanged: the `infoIcon` span creation with class `tubesage-settings-info-icon` and the `aria-label`, the `setTooltip(infoIcon, tooltipText, ...)` call, and `return infoIcon`.
|
||||
|
||||
- [ ] **Step 2: Build and lint**
|
||||
|
||||
Run: `npm run build` — expect no TypeScript errors.
|
||||
Run: `npm run lint` — expect 0 errors, 0 warnings.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor(ui): render settings info icon with native setIcon"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Icon buttons via `ExtraButtonComponent`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — license-view button (~3792), README-view button (~3862), copy button (~6352)
|
||||
- Modify: `styles.css` — `.tubesage-icon-button` / `.tubesage-icon-button-hover` if they become unused
|
||||
|
||||
Three buttons are raw `<button class="tubesage-icon-button">` elements with a hand-built SVG child, manual `mouseenter`/`mouseleave` hover-class handlers, and a click handler:
|
||||
- the license-view eye button (~`main.ts:3792`) — opens `new LicenseModal(this.app)`
|
||||
- the README-view eye button (~`main.ts:3862`) — opens `new READMEModal(this.app)`
|
||||
- the copy button in `TemplateViewModal` (~`main.ts:6352`) — runs `handleCopy` (`navigator.clipboard.writeText`)
|
||||
|
||||
- [ ] **Step 1: Convert each icon button to `ExtraButtonComponent`**
|
||||
|
||||
For each of the three, replace the `createEl('button', ...)` + the `createElementNS` SVG block + the `mouseenter`/`mouseleave` handlers with:
|
||||
|
||||
```ts
|
||||
new ExtraButtonComponent(container)
|
||||
.setIcon('eye') // 'eye' for the two view buttons, 'copy' for the copy button
|
||||
.setTooltip('<existing tooltip text>')
|
||||
.onClick(() => { /* existing click handler body */ });
|
||||
```
|
||||
|
||||
`ExtraButtonComponent` is already imported in `main.ts`. It renders an icon button with built-in hover styling and an accessible tooltip, so the hand-built SVG, the manual hover handlers, and (for the copy button) the separate tooltip wiring are all replaced. `container` is the element the old button was appended to (the existing `*ButtonContainer` / `copyContainer`).
|
||||
|
||||
Preserve exactly: the icon meaning (eye/eye/copy), the tooltip text each button currently has, and the click behavior. For the copy button, `handleCopy` and its `copyTextElement` success/error feedback stay; if `copyTextElement` was found via `copyContainer.querySelector('span')`, keep that lookup working (the "Copy template" label span still exists).
|
||||
|
||||
- [ ] **Step 2: Remove now-dead icon-button CSS**
|
||||
|
||||
After the conversion, run `grep -rn "tubesage-icon-button" main.ts`. If there are no remaining uses, delete the `.tubesage-icon-button` and `.tubesage-icon-button-hover` rules from `styles.css`. If any use remains, leave the rules.
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build` — expect no TypeScript errors.
|
||||
Run: `npm run lint` — expect 0 errors, 0 warnings.
|
||||
Run: `grep -rn "createElementNS" main.ts` — the eye and copy SVG blocks should be gone (only any unrelated SVG construction may remain).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts styles.css
|
||||
git commit -m "refactor(ui): convert icon buttons to ExtraButtonComponent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Text buttons via `ButtonComponent`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — Process button (~2925), three Close buttons (~5756, ~6089, ~6474), Open-settings button (~5854) and the LicenseRequiredModal Close button (~5860); the obsidian import (line 1)
|
||||
|
||||
Six raw text `<button>`s, each created with `createEl('button', { text, cls })` plus a click `addEventListener`:
|
||||
- `YouTubeTranscriptModal` Process button (~2925) — carries `mod-cta`, conditionally `tubesage-process-btn-mobile`
|
||||
- `LicenseModal` Close (~5756, class `tubesage-license-close-button`)
|
||||
- `LicenseRequiredModal` Open-settings (~5854) and Close (~5860)
|
||||
- `READMEModal` Close (~6089)
|
||||
- `TemplateViewModal` Close (~6474)
|
||||
|
||||
- [ ] **Step 1: Add `ButtonComponent` to the obsidian import**
|
||||
|
||||
Add `ButtonComponent` to the `import { ... } from 'obsidian'` line at the top of `main.ts`.
|
||||
|
||||
- [ ] **Step 2: Convert each text button**
|
||||
|
||||
For each, replace `createEl('button', { text, cls })` + the click `addEventListener` with:
|
||||
|
||||
```ts
|
||||
new ButtonComponent(container)
|
||||
.setButtonText('<existing text>')
|
||||
.onClick(() => { /* existing click handler body */ });
|
||||
```
|
||||
|
||||
Then preserve look-and-feel:
|
||||
- The **Process** button: instead of the `mod-cta` class, call `.setCta()` (the native equivalent). Keep the mobile branch — if the code adds `tubesage-process-btn-mobile` on mobile, add it to the component's `.buttonEl` instead (`processButton.buttonEl.addClass('tubesage-process-btn-mobile')`). Keep the existing process-click logic unchanged.
|
||||
- The **Close / Open-settings** buttons each carry a bespoke class (`tubesage-license-close-button`, `tubesage-license-required-button-primary`, `tubesage-license-required-button-secondary`, `tubesage-readme-close-button`, and the TemplateViewModal close's class). Re-apply that same class to the component's `.buttonEl` (`btn.buttonEl.addClass('<class>')`) so the styling is identical. Do not remove these CSS rules — they still carry the look.
|
||||
|
||||
Preserve every click handler body exactly (`this.close()`, the open-settings `app.setting.open('tubesage')` logic, the Process flow).
|
||||
|
||||
- [ ] **Step 3: Build and lint**
|
||||
|
||||
Run: `npm run build` — expect no TypeScript errors.
|
||||
Run: `npm run lint` — expect 0 errors, 0 warnings.
|
||||
Run: `grep -rn "createEl('button'" main.ts` — expect no matches (all nine buttons across Tasks 2 and 3 are now components).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor(ui): convert text buttons to ButtonComponent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create-note modal inputs via `TextComponent` / `DropdownComponent`
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.ts` — `YouTubeTranscriptModal.buildInputStage()` (~lines 2835-3015)
|
||||
|
||||
The modal builds raw inputs: a URL `<input type=text>`, a title `<input type=text>`, and a video-count `<select>`. The class keeps element references `this.urlInputEl` and `this.titleInputEl`.
|
||||
|
||||
- [ ] **Step 1: Convert the URL and title inputs to `TextComponent`**
|
||||
|
||||
Replace each raw `createEl('input', { type: 'text', ... })` with a `TextComponent`:
|
||||
|
||||
```ts
|
||||
const urlText = new TextComponent(urlGroup);
|
||||
urlText.setPlaceholder(YOUTUBE_URL_PLACEHOLDER);
|
||||
this.urlInputEl = urlText.inputEl;
|
||||
```
|
||||
|
||||
`TextComponent` is already imported. Repoint the class fields `this.urlInputEl` and `this.titleInputEl` to the component's `.inputEl`, so all existing code that uses those references (focus calls, `.value` reads, the `input` event listener for URL validation, the `keydown`/Enter handling) keeps working unchanged. Keep the placeholders identical (URL: `YOUTUBE_URL_PLACEHOLDER`; title: its current placeholder). Keep the `form-group` / `label` layout containers as they are.
|
||||
|
||||
- [ ] **Step 2: Convert the video-count `<select>` to `DropdownComponent`**
|
||||
|
||||
Replace the raw `createEl('select', ...)` plus its `for` loop of `createEl('option', ...)` with:
|
||||
|
||||
```ts
|
||||
const videoCountDropdown = new DropdownComponent(limitedOptionContainer);
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
videoCountDropdown.addOption(String(i), String(i));
|
||||
}
|
||||
videoCountDropdown.setValue('1');
|
||||
```
|
||||
|
||||
`DropdownComponent` is already imported. Where the old code read `videoCountDropdown.value`, read `videoCountDropdown.getValue()` instead. The "Process" click handler reads this value to decide the video count — update that read accordingly. Keep the `video-count-dropdown` class on the component's `.selectEl` if it carries sizing (`videoCountDropdown.selectEl.addClass('video-count-dropdown')`).
|
||||
|
||||
- [ ] **Step 3: Leave the radio buttons unchanged**
|
||||
|
||||
The "All videos" / "Limited number" radio `<input type=radio>` elements stay as raw inputs — Obsidian has no native radio component. Do not change them.
|
||||
|
||||
- [ ] **Step 4: Build and lint**
|
||||
|
||||
Run: `npm run build` — expect no TypeScript errors.
|
||||
Run: `npm run lint` — expect 0 errors, 0 warnings.
|
||||
Run: `grep -rn "createEl('input'\|createEl('select'" main.ts` — the only remaining `createEl('input'` matches should be the radio buttons; no `createEl('select'` should remain.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add main.ts
|
||||
git commit -m "refactor(ui): convert create-note modal inputs to native components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npm run build` is green.
|
||||
- [ ] `npm run lint` reports 0 errors and 0 warnings.
|
||||
- [ ] `grep -rn "createEl('button'" main.ts` returns nothing.
|
||||
- [ ] `grep -rn "createEl('select'" main.ts` returns nothing; `createEl('input'` returns only the radio buttons.
|
||||
- [ ] The hand-built eye/info/copy SVG blocks are gone (`grep -rn "createElementNS" main.ts` shows only any unrelated remaining SVG, if any).
|
||||
- [ ] Manual audition against the 1.3.3 release in a dev vault: the create-note modal (URL field, title field, video-count dropdown, Process button), the settings info icons, the license/README eye buttons, the copy button, and every Close / Open-settings button look and behave the same as 1.3.3. Note any pixel drift for a follow-up CSS tweak.
|
||||
|
|
@ -1,516 +0,0 @@
|
|||
# Transcript Fallback: iOS InnerTube Player Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the four broken free local transcript-extraction methods in `src/youtube-transcript.ts` with a single working method built on YouTube's InnerTube iOS player API.
|
||||
|
||||
**Architecture:** The transcript fallback ladder in `YouTubeTranscriptExtractor.fetchTranscript` becomes three rungs — ScrapeCreators paid API, the new iOS InnerTube player method, Supadata paid API. The iOS player response carries both caption tracks and `videoDetails`, so it also replaces the watch-page HTML scrape that `getVideoMetadata` used. All watch-page / ANDROID / MWEB / WEB-`get_transcript` code and its helpers are deleted.
|
||||
|
||||
**Tech Stack:** TypeScript (strict), esbuild, the `obsidianFetch` cross-platform HTTP shim. Single file: `src/youtube-transcript.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Important context for the implementer
|
||||
|
||||
- **No unit-test framework exists in this repo**, and `src/youtube-transcript.ts` calls live YouTube endpoints that cannot be exercised in CI. There are therefore **no "write a failing test" steps**. The verification gate for every task is `npm run build` (tsc strict + esbuild) and `npm run lint` (must report `0 errors, 0 warnings`).
|
||||
- **Hard project rule: never add an `eslint-disable` comment anywhere.** If lint complains, fix the code.
|
||||
- TypeScript and the project's ESLint config do **not** flag an unused *private class method*. That is why Task 1 can add `fetchViaIosPlayer` before Task 2 wires it in — the build stays green.
|
||||
- The tasks are ordered so the build compiles cleanly after every commit: Task 1 adds new code, Task 2 switches callers to it, Task 3 deletes the now-unreferenced old code.
|
||||
- Existing file-level helpers you will reuse: `isRecord(value): value is Record<string, unknown>`, `isString(value): value is string`, the type alias `UnknownRecord = Record<string, unknown>`, the logger `transcriptLogger`, `getSafeErrorMessage`, and the `obsidianFetch` shim.
|
||||
- Existing methods you will reuse and must NOT change: `pickBestTrack(tracks, requestedLang)` → returns `{ track: CaptionTrack; useTlang: boolean } | null`; `fetchCaptionTrack(baseUrl, tlang?, userAgent?)` → `Promise<TranscriptSegment[]>`.
|
||||
- Existing interfaces you will reuse: `CaptionTrack` (`languageCode: string; kind?: string; baseUrl?: string; vssId?: string; isTranslatable?: boolean`), `TranscriptOptions`, `TranscriptMetadata` (`title?: string; author?: string`), `TranscriptResult` (`segments: TranscriptSegment[]; metadata: TranscriptMetadata`).
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/youtube-transcript.ts` — the only file changed. It currently holds the whole `YouTubeTranscriptExtractor` class. The plan adds two methods + four module constants + two statics, rewires three call sites, and removes the obsolete methods/helpers. Net effect: the file shrinks substantially.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the iOS InnerTube player method
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/youtube-transcript.ts`
|
||||
|
||||
This task adds new code only. Nothing calls `fetchViaIosPlayer` yet — that is Task 2. The build stays green because TypeScript does not flag unused private methods.
|
||||
|
||||
- [ ] **Step 1: Add the four module-level constants**
|
||||
|
||||
In `src/youtube-transcript.ts`, the file begins with imports, then `type UnknownRecord = ...`, `isRecord`, and `isString` (the `isString` line is `const isString = (value: unknown): value is string => typeof value === 'string';`). Immediately **after** the `isString` line and **before** the `// Add the CaptionTrack type at file level` comment, insert:
|
||||
|
||||
```ts
|
||||
// YouTube InnerTube iOS player endpoint.
|
||||
// See docs/superpowers/specs/2026-05-18-transcript-fallback-ios-player-design.md
|
||||
// The iOS client still returns working caption track URLs without PO tokens,
|
||||
// while the Android/MWEB/WEB clients stopped doing so in early 2026.
|
||||
const INNERTUBE_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||||
const INNERTUBE_PLAYER_URL = `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}`;
|
||||
const IOS_USER_AGENT = 'com.google.ios.youtube/20.10.38 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X)';
|
||||
const IOS_CLIENT_VERSION = '20.10.38';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the iOS player cache statics**
|
||||
|
||||
In the `YouTubeTranscriptExtractor` class, find the existing static field block near the top of the class:
|
||||
|
||||
```ts
|
||||
private static cookieStore: string = '';
|
||||
// Cache YouTube config after first extraction to avoid repeated HTML fetches within the same video request
|
||||
private static cachedConfig: YouTubeConfig | null = null;
|
||||
private static cachedVideoId: string | null = null;
|
||||
```
|
||||
|
||||
Immediately **after** the `private static cachedVideoId: string | null = null;` line, add:
|
||||
|
||||
```ts
|
||||
// Cache the iOS player API response per video to avoid a redundant POST within one request
|
||||
private static cachedPlayerData: UnknownRecord | null = null;
|
||||
private static cachedPlayerVideoId: string | null = null;
|
||||
```
|
||||
|
||||
(Leave `cookieStore`, `cachedConfig`, `cachedVideoId` in place for now — Task 3 removes them.)
|
||||
|
||||
- [ ] **Step 3: Add the `fetchIosPlayerData` helper method**
|
||||
|
||||
In `src/youtube-transcript.ts`, find the method `fetchViaScrapeCreators` and its closing brace (it ends with `return { segments, metadata };` followed by a line containing only ` }`). Immediately **after** that closing brace, insert this method:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Perform the YouTube InnerTube iOS player API call and return the parsed JSON.
|
||||
* The iOS client currently still exposes working caption track URLs and videoDetails.
|
||||
* The response is cached per videoId to avoid a redundant POST within one request.
|
||||
*/
|
||||
private static async fetchIosPlayerData(videoId: string, options: TranscriptOptions): Promise<UnknownRecord> {
|
||||
if (this.cachedPlayerData && this.cachedPlayerVideoId === videoId) {
|
||||
transcriptLogger.debug('Using cached iOS player data');
|
||||
return this.cachedPlayerData;
|
||||
}
|
||||
|
||||
const lang = options.lang || 'en';
|
||||
const country = options.country || 'US';
|
||||
|
||||
const body = JSON.stringify({
|
||||
context: {
|
||||
client: {
|
||||
clientName: 'IOS',
|
||||
clientVersion: IOS_CLIENT_VERSION,
|
||||
hl: lang,
|
||||
gl: country
|
||||
}
|
||||
},
|
||||
videoId
|
||||
});
|
||||
|
||||
transcriptLogger.debug(`iOS Player API: requesting player data for ${videoId}`);
|
||||
|
||||
const response = await obsidianFetch(INNERTUBE_PLAYER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': IOS_USER_AGENT
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`iOS Player API error: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as UnknownRecord;
|
||||
|
||||
const playabilityStatus = data.playabilityStatus;
|
||||
if (isRecord(playabilityStatus)) {
|
||||
const status = playabilityStatus.status;
|
||||
const reason = isString(playabilityStatus.reason) ? playabilityStatus.reason : undefined;
|
||||
if (status === 'ERROR') {
|
||||
throw new Error(reason || 'Video unavailable');
|
||||
}
|
||||
if (status === 'LOGIN_REQUIRED') {
|
||||
throw new Error('This video requires login to view');
|
||||
}
|
||||
if (status === 'UNPLAYABLE') {
|
||||
throw new Error(reason || 'Video is unplayable');
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedPlayerData = data;
|
||||
this.cachedPlayerVideoId = videoId;
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `fetchViaIosPlayer` method**
|
||||
|
||||
Immediately **after** the `fetchIosPlayerData` method you just added (after its closing ` }`), insert:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* iOS InnerTube player method: the working local transcript fallback.
|
||||
* Fetches the iOS player response, picks a caption track, downloads and parses it.
|
||||
*/
|
||||
private static async fetchViaIosPlayer(videoId: string, options: TranscriptOptions): Promise<TranscriptResult> {
|
||||
const lang = options.lang || 'en';
|
||||
|
||||
const data = await this.fetchIosPlayerData(videoId, options);
|
||||
|
||||
// Metadata from videoDetails
|
||||
const videoDetails = isRecord(data.videoDetails) ? data.videoDetails : undefined;
|
||||
const metadata: TranscriptMetadata = {
|
||||
title: videoDetails && isString(videoDetails.title) ? videoDetails.title : undefined,
|
||||
author: videoDetails && isString(videoDetails.author) ? videoDetails.author : undefined
|
||||
};
|
||||
|
||||
// Caption tracks
|
||||
const captions = (data as {
|
||||
captions?: {
|
||||
playerCaptionsTracklistRenderer?: {
|
||||
captionTracks?: Array<{
|
||||
baseUrl?: string;
|
||||
languageCode?: string;
|
||||
kind?: string;
|
||||
vssId?: string;
|
||||
isTranslatable?: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}).captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
||||
|
||||
if (!captions || !Array.isArray(captions) || captions.length === 0) {
|
||||
throw new Error('No captions available for this video');
|
||||
}
|
||||
|
||||
transcriptLogger.debug(`iOS Player API: found ${captions.length} caption tracks`);
|
||||
|
||||
const tracks: CaptionTrack[] = captions.map(track => ({
|
||||
languageCode: track.languageCode || '',
|
||||
kind: track.kind,
|
||||
baseUrl: track.baseUrl,
|
||||
vssId: track.vssId,
|
||||
isTranslatable: track.isTranslatable
|
||||
}));
|
||||
|
||||
const selection = this.pickBestTrack(tracks, lang);
|
||||
if (!selection || !selection.track.baseUrl) {
|
||||
throw new Error('No suitable caption track with baseUrl found');
|
||||
}
|
||||
|
||||
const { track, useTlang } = selection;
|
||||
const trackBaseUrl = track.baseUrl as string; // Guaranteed non-null by the check above
|
||||
transcriptLogger.debug(`iOS Player API: selected track lang=${track.languageCode}, kind=${track.kind || 'manual'}, useTlang=${useTlang}`);
|
||||
|
||||
const tlang = useTlang ? lang : undefined;
|
||||
const segments = await this.fetchCaptionTrack(trackBaseUrl, tlang, IOS_USER_AGENT);
|
||||
|
||||
transcriptLogger.debug(`iOS Player API: successfully extracted ${segments.length} segments`);
|
||||
return { segments, metadata };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the build is clean**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: exits 0, no TypeScript errors.
|
||||
|
||||
- [ ] **Step 6: Verify lint is clean**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: `0 errors, 0 warnings`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/youtube-transcript.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: add iOS InnerTube player transcript method
|
||||
|
||||
Add fetchIosPlayerData and fetchViaIosPlayer, built from the YouTube
|
||||
iOS player approach. Not yet wired into the fallback ladder.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewire the fallback ladder and metadata lookup to the iOS method
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/youtube-transcript.ts`
|
||||
|
||||
This task switches `fetchTranscript` to the new three-rung ladder and re-points `getVideoMetadata` at the iOS player call. After this task the four old methods are still defined but no longer called — that is fine, the build stays green; Task 3 deletes them.
|
||||
|
||||
- [ ] **Step 1: Replace the video-ID cache-reset block in `fetchTranscript`**
|
||||
|
||||
In `fetchTranscript`, find this block near the start of the method:
|
||||
|
||||
```ts
|
||||
// Clear cached config when video changes to prevent stale title/metadata from a previous request
|
||||
if (this.cachedVideoId !== videoId) {
|
||||
this.cachedConfig = null;
|
||||
this.cachedVideoId = videoId;
|
||||
transcriptLogger.debug(`New video ID detected (${videoId}), cleared config cache`);
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
// Clear cached player data when the video changes to prevent stale metadata
|
||||
if (this.cachedPlayerVideoId !== videoId) {
|
||||
this.cachedPlayerData = null;
|
||||
this.cachedPlayerVideoId = videoId;
|
||||
transcriptLogger.debug(`New video ID detected (${videoId}), cleared player cache`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the fallback ladder body in `fetchTranscript`**
|
||||
|
||||
In `fetchTranscript`, find the `try {` that opens with `const attempts: Array<{ method: string; error: string }> = [];` and runs through the six numbered method blocks (`[1]` ScrapeCreators … `[6]` Supadata) up to and including the `throw new Error(\`All transcript extraction methods failed ...\`);` line. Replace everything from `const attempts:` down to that `throw new Error(...)` line (the entire body inside that `try`, but NOT the `try {` line itself and NOT the `} catch (error) {` that follows) with:
|
||||
|
||||
```ts
|
||||
const attempts: Array<{ method: string; error: string }> = [];
|
||||
let metadata: TranscriptMetadata = {};
|
||||
|
||||
// [1] ScrapeCreators paid API — run first when key is present (most reliable)
|
||||
if (options.scrapcreatorsApiKey) {
|
||||
try {
|
||||
transcriptLogger.debug('ScrapeCreators API key present — attempting paid API first');
|
||||
const result = await this.fetchViaScrapeCreators(videoId, options);
|
||||
transcriptLogger.debug(`ScrapeCreators API succeeded with ${result.segments.length} segments`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = getSafeErrorMessage(err);
|
||||
transcriptLogger.debug('ScrapeCreators API failed, falling back to local method:', msg);
|
||||
attempts.push({ method: 'ScrapeCreators API', error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// [2] iOS InnerTube player API — the working local method
|
||||
try {
|
||||
transcriptLogger.debug('Attempting local method: iOS InnerTube player API');
|
||||
const result = await this.fetchViaIosPlayer(videoId, options);
|
||||
transcriptLogger.debug(`iOS player method succeeded with ${result.segments.length} segments`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = getSafeErrorMessage(err);
|
||||
transcriptLogger.debug('iOS player method failed:', msg);
|
||||
attempts.push({ method: 'iOS player', error: msg });
|
||||
// If the player call itself succeeded but captions were absent, the
|
||||
// response (with videoDetails) is cached — recover metadata from it.
|
||||
const cached = this.cachedPlayerData;
|
||||
if (isRecord(cached) && isRecord(cached.videoDetails)) {
|
||||
metadata = {
|
||||
title: isString(cached.videoDetails.title) ? cached.videoDetails.title : undefined,
|
||||
author: isString(cached.videoDetails.author) ? cached.videoDetails.author : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// [3] Supadata paid API fallback (only if key is configured)
|
||||
if (options.supadataApiKey) {
|
||||
try {
|
||||
transcriptLogger.debug('Local method failed — attempting Supadata paid API');
|
||||
const result = await this.fetchViaSupadata(videoId, options);
|
||||
transcriptLogger.debug(`Supadata API succeeded with ${result.segments.length} segments`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = getSafeErrorMessage(err);
|
||||
transcriptLogger.debug('Supadata API failed:', msg);
|
||||
attempts.push({ method: 'Supadata API', error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// All methods failed
|
||||
const attemptedMethods = attempts.map(a => a.method).join(', ');
|
||||
const lastError = attempts[attempts.length - 1]?.error || 'Unknown error';
|
||||
transcriptLogger.error(`All transcript methods failed: ${attemptedMethods}`);
|
||||
|
||||
if (metadata.title || metadata.author) {
|
||||
transcriptLogger.debug('Returning metadata despite caption failure');
|
||||
return {
|
||||
segments: [{
|
||||
text: `[TRANSCRIPT EXTRACTION FAILED: ${attemptedMethods} methods all failed. ${lastError}]`,
|
||||
start: 0,
|
||||
duration: 0
|
||||
}],
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`All transcript extraction methods failed (${attemptedMethods}). Last error: ${lastError}`);
|
||||
```
|
||||
|
||||
Leave the `} catch (error) {` block that follows (the CORS/network error mapping) exactly as it is.
|
||||
|
||||
- [ ] **Step 3: Replace the body of `getVideoMetadata`**
|
||||
|
||||
Find the `getVideoMetadata` method. Its current signature line is `static async getVideoMetadata(videoId: string): Promise<TranscriptMetadata> {`. Replace the **entire method** (signature through its closing brace) with:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Get video metadata (title, author) from the iOS player response.
|
||||
* Used by the ScrapeCreators and Supadata paths, which return transcript text only.
|
||||
* The iOS player response is cached per videoId, so this reuses a warm cache when present.
|
||||
*/
|
||||
static async getVideoMetadata(videoId: string): Promise<TranscriptMetadata> {
|
||||
try {
|
||||
const data = await this.fetchIosPlayerData(videoId, {});
|
||||
const videoDetails = isRecord(data.videoDetails) ? data.videoDetails : undefined;
|
||||
return {
|
||||
title: videoDetails && isString(videoDetails.title) ? videoDetails.title : undefined,
|
||||
author: videoDetails && isString(videoDetails.author) ? videoDetails.author : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
transcriptLogger.error('Error fetching video metadata:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the build is clean**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: exits 0, no TypeScript errors. (The four old `fetchVia*` local methods and their helpers are now uncalled but still defined — TypeScript does not flag unused private methods, so this compiles.)
|
||||
|
||||
- [ ] **Step 5: Verify lint is clean**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: `0 errors, 0 warnings`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/youtube-transcript.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: route transcript fallback through the iOS player method
|
||||
|
||||
fetchTranscript now uses a three-rung ladder (ScrapeCreators, iOS
|
||||
player, Supadata). getVideoMetadata is sourced from the iOS player
|
||||
response instead of the watch-page HTML scrape.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Delete the obsolete methods, helpers, and statics
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/youtube-transcript.ts`
|
||||
|
||||
Everything removed here is now unreferenced (verified: `parseScrapeCreatorsTranscript` was used only inside `fetchViaWebScrapeCreators`; `getYouTubeConfig`/`fetchWatchPageHtml`/`extractJsonFromHtml` only by the deleted methods after Task 2). The `USER_AGENT` static is **kept** because `fetchCaptionTrackRaw` still uses it.
|
||||
|
||||
- [ ] **Step 1: Delete the four obsolete transcript methods**
|
||||
|
||||
Delete each of these methods in full (the leading JSDoc comment block through the method's closing brace). Locate them by their signature lines:
|
||||
|
||||
1. `private static async fetchViaWatchPage(videoId: string, options: TranscriptOptions): Promise<TranscriptResult> {`
|
||||
2. `private static async fetchViaWebScrapeCreators(videoId: string, _options: TranscriptOptions): Promise<TranscriptResult> {`
|
||||
3. `private static async fetchViaPlayerApiMWEB(videoId: string, options: TranscriptOptions): Promise<TranscriptResult> {`
|
||||
4. `private static async fetchViaPlayerApiAndroid(videoId: string, options: TranscriptOptions): Promise<TranscriptResult> {`
|
||||
|
||||
- [ ] **Step 2: Delete the five obsolete helper methods**
|
||||
|
||||
Delete each of these methods in full (JSDoc through closing brace), located by signature:
|
||||
|
||||
1. `private static async getYouTubeConfig(videoId: string): Promise<YouTubeConfig> {`
|
||||
2. `private static async fetchWatchPageHtml(watchUrl: string, extraCookie?: string): Promise<string> {`
|
||||
3. `private static extractJsonFromHtml(html: string, variableName: string): unknown {`
|
||||
4. `private static extractMetadataFromNextData(nextData: unknown): TranscriptMetadata {`
|
||||
5. `private static findTranscriptEndpoint(obj: unknown): string | null {`
|
||||
6. `private static parseScrapeCreatorsTranscript(transcriptData: UnknownRecord): TranscriptSegment[] {`
|
||||
|
||||
(That is six methods — delete all six.)
|
||||
|
||||
- [ ] **Step 3: Delete the `YouTubeConfig` interface**
|
||||
|
||||
Near the top of the file, delete this interface in full (JSDoc comment through closing brace):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* YouTube configuration extracted from watch page
|
||||
*/
|
||||
interface YouTubeConfig {
|
||||
apiKey: string;
|
||||
clientVersion: string;
|
||||
visitorData: string | null;
|
||||
captionTracks: CaptionTrack[];
|
||||
metadata: TranscriptMetadata;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete the three obsolete class statics**
|
||||
|
||||
In the `YouTubeTranscriptExtractor` static field block, delete these three lines:
|
||||
|
||||
```ts
|
||||
private static cookieStore: string = '';
|
||||
```
|
||||
```ts
|
||||
// Cache YouTube config after first extraction to avoid repeated HTML fetches within the same video request
|
||||
private static cachedConfig: YouTubeConfig | null = null;
|
||||
private static cachedVideoId: string | null = null;
|
||||
```
|
||||
```ts
|
||||
// Fallback client version if extraction fails (updated to current version)
|
||||
private static readonly FALLBACK_CLIENT_VERSION = '2.20260128.05.00';
|
||||
```
|
||||
|
||||
Keep `private static readonly USER_AGENT = ...` and the two `cachedPlayerData` / `cachedPlayerVideoId` statics added in Task 1.
|
||||
|
||||
- [ ] **Step 5: Remove the dead `cookieStore` header spread in `fetchCaptionTrackRaw`**
|
||||
|
||||
In the kept method `fetchCaptionTrackRaw`, the headers object ends with this line:
|
||||
|
||||
```ts
|
||||
...(YouTubeTranscriptExtractor.cookieStore && { 'Cookie': YouTubeTranscriptExtractor.cookieStore })
|
||||
```
|
||||
|
||||
Delete that line. The line above it (`'DNT': '1',`) becomes the last header — make sure the resulting object is still valid (the trailing comma after `'DNT': '1'` is harmless and may stay). Do not change any other header in this method.
|
||||
|
||||
- [ ] **Step 6: Verify the build is clean**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: exits 0, no TypeScript errors. In particular, no "cannot find name" errors — if any appear, a deletion removed something still referenced; re-check Steps 1-5.
|
||||
|
||||
- [ ] **Step 7: Verify lint is clean**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: `0 errors, 0 warnings`. No `eslint-disable` comments may be added to achieve this.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/youtube-transcript.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor: remove broken watch-page/ANDROID/MWEB/WEB transcript methods
|
||||
|
||||
Delete the four obsolete local transcript methods, their six helper
|
||||
methods, the YouTubeConfig interface, and the now-unused statics. The
|
||||
iOS player method added earlier is the sole local fallback.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Spec "New fallback ladder" (ScrapeCreators → iOS player → Supadata) → Task 2 Step 2. ✓
|
||||
- Spec "`fetchIosPlayerData` helper" (constants, POST, playability checks, per-video cache) → Task 1 Steps 1-3. ✓
|
||||
- Spec "`fetchViaIosPlayer` method" (videoDetails metadata, captionTracks, `pickBestTrack`, `fetchCaptionTrack` with iOS UA + tlang) → Task 1 Step 4. ✓
|
||||
- Spec "Changed: `getVideoMetadata`" (re-pointed to `fetchIosPlayerData`, try/catch → `{}`, short-circuit removed) → Task 2 Step 3. ✓
|
||||
- Spec "Deletions" (4 methods, 6 helpers, `YouTubeConfig`, statics `cachedConfig`/`cachedVideoId`/`FALLBACK_CLIENT_VERSION`/`cookieStore`, the `cookieStore` header spread) → Task 3 Steps 1-5. ✓
|
||||
- Spec "video-ID change clears the player cache" → Task 2 Step 1. ✓
|
||||
- Spec "Kept unchanged" (ScrapeCreators/Supadata, `pickBestTrack`, caption fetch/parse, `USER_AGENT`) → not touched by any task; Task 3 Step 4 explicitly keeps `USER_AGENT`. ✓
|
||||
- Spec "Testing" (build + lint clean, no eslint-disable) → every task ends with build + lint steps. ✓
|
||||
- No spec requirement is unaddressed.
|
||||
|
||||
**2. Placeholder scan:** No TBD/TODO/vague steps. Every code step shows complete code; every command shows expected output. ✓
|
||||
|
||||
**3. Type consistency:** `fetchIosPlayerData` returns `Promise<UnknownRecord>` and is consumed by `fetchViaIosPlayer` and `getVideoMetadata`, both treating the result via `isRecord`/`isString` guards on `data.videoDetails` / `data.playabilityStatus` / `data.captions`. `fetchViaIosPlayer` returns `Promise<TranscriptResult>` and is called in `fetchTranscript`'s `[2]` block expecting `result.segments`. The cache statics `cachedPlayerData: UnknownRecord | null` / `cachedPlayerVideoId: string | null` are written in `fetchIosPlayerData`, reset in `fetchTranscript` Step 1, and read in `fetchTranscript` Step 2's catch block — names consistent throughout. `CaptionTrack` shape matches the existing interface. ✓
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
# Archon Graphified Workflows — Design
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Scope**: Two TubeSage-local Archon workflows that clone existing defaults and add (a) graphify-driven codebase context up front, (b) Superpowers skills on the right nodes, (c) a graphify refresh + commit at the end so the graph travels with the PR.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Two new workflows in `TubeSage/.archon/workflows/`:
|
||||
- `archon-graph-superpowers-feature` — for net-new features / requirements (clone of `archon-idea-to-pr`).
|
||||
- `archon-graph-superpowers-fix-issue` — for GitHub issues / bugs (clone of `archon-fix-github-issue`).
|
||||
2. Each workflow ingests codebase context from `graphify-out/graph.json` before any planning or investigation, via `graphify query`.
|
||||
3. Each workflow runs `graphify . --update` after the implementation succeeds and bundles the refreshed `graphify-out/graph.json` into the PR.
|
||||
4. Each workflow attaches Superpowers skills at the nodes where they're most relevant — planning, implementation, validation, receiving-review, and finishing.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing or modifying the default workflows.
|
||||
- Adding semantic re-extraction (`graphify` with API calls) — only the AST-only `--update` runs inside the workflow.
|
||||
- Adding skill content to the graph — graphify only indexes code.
|
||||
- Building a new command file unless an existing default doesn't cover the use case.
|
||||
|
||||
## Source workflows
|
||||
|
||||
Both lifted verbatim from `/Volumes/HomeExt/Users/rmccorkl/Code/Archon/.archon/workflows/defaults/`:
|
||||
|
||||
- `archon-idea-to-pr.yaml` — 8 phases: create-plan → plan-setup → confirm-plan → implement-tasks → validate → finalize-pr → review (5 parallel agents + synthesize) → implement-fixes → workflow-summary.
|
||||
- `archon-fix-github-issue.yaml` — 10 phases: extract/fetch/classify issue → web-research → investigate-or-plan → bridge-artifacts → implement → validate → create-pr → review-scope/classify → conditional review agents → synthesize → self-fix → simplify → report.
|
||||
|
||||
The clones reuse every existing command node by reference. No commands are copied — they resolve from the global Archon defaults chain.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
TubeSage/
|
||||
├── .archon/
|
||||
│ └── workflows/
|
||||
│ ├── archon-graph-superpowers-feature.yaml
|
||||
│ └── archon-graph-superpowers-fix-issue.yaml
|
||||
└── graphify-out/ # already exists; consumed and refreshed
|
||||
```
|
||||
|
||||
No `.archon/commands/`, no `.archon/config.yaml`. Project-scoped workflows shadow defaults by filename; new names mean they appear additively without overriding anything.
|
||||
|
||||
## Node additions (common to both workflows)
|
||||
|
||||
### A. `graph-health-check` (bash, advisory)
|
||||
|
||||
```yaml
|
||||
- id: graph-health-check
|
||||
bash: |
|
||||
set -e
|
||||
if [ ! -f graphify-out/graph.json ]; then
|
||||
echo "WARN: graphify-out/graph.json missing — skipping context acquisition" >&2
|
||||
echo "missing" > "$ARTIFACTS_DIR/.graph-state"
|
||||
exit 0
|
||||
fi
|
||||
graphify check-update graphify-out 2>&1 || true
|
||||
echo "ok" > "$ARTIFACTS_DIR/.graph-state"
|
||||
timeout: 15000
|
||||
```
|
||||
|
||||
Soft-fails to `missing` state when the graph isn't present so downstream nodes can skip cleanly. The TubeSage repo already has `graphify-out/` so the happy path applies.
|
||||
|
||||
### B. `graph-context` (bash)
|
||||
|
||||
Runs first-class graph query against the user's request:
|
||||
|
||||
```yaml
|
||||
- id: graph-context
|
||||
bash: |
|
||||
set -e
|
||||
if [ "$(cat "$ARTIFACTS_DIR/.graph-state" 2>/dev/null || echo missing)" = "missing" ]; then
|
||||
echo "No graph available — writing empty context"
|
||||
echo "_(graphify-out/graph.json missing — context unavailable)_" > "$ARTIFACTS_DIR/codebase-context.md"
|
||||
exit 0
|
||||
fi
|
||||
{
|
||||
echo "# Codebase context (graphify query)"
|
||||
echo
|
||||
echo "**Question**: $ARGUMENTS"
|
||||
echo
|
||||
echo '```'
|
||||
graphify query "$ARGUMENTS" --budget 4000 --graph graphify-out/graph.json
|
||||
echo '```'
|
||||
} > "$ARTIFACTS_DIR/codebase-context.md"
|
||||
depends_on: [graph-health-check]
|
||||
timeout: 60000
|
||||
```
|
||||
|
||||
The output is a markdown file at `$ARTIFACTS_DIR/codebase-context.md`. Subsequent planning/investigation nodes read it via `$ARTIFACTS_DIR` (their command prompts already reference `$ARTIFACTS_DIR` — see `archon-create-plan`, `archon-investigate-issue`).
|
||||
|
||||
### C. `graph-update` (bash)
|
||||
|
||||
```yaml
|
||||
- id: graph-update
|
||||
bash: |
|
||||
set -e
|
||||
if ! command -v graphify >/dev/null 2>&1; then
|
||||
echo "graphify CLI not on PATH — skipping refresh" >&2
|
||||
exit 0
|
||||
fi
|
||||
graphify . --update
|
||||
if ! git diff --quiet graphify-out/graph.json 2>/dev/null; then
|
||||
git add graphify-out/graph.json
|
||||
git commit -m "chore: refresh graphify graph after delivery"
|
||||
echo "graph refreshed and committed"
|
||||
else
|
||||
echo "graph unchanged — no commit"
|
||||
fi
|
||||
timeout: 120000
|
||||
```
|
||||
|
||||
Runs after `validate` and before the local merge-back. The global PostToolUse hook already runs `graphify update .` after each Edit/Write but has a hard 8s timeout — this node is the deliberate, larger-budget backstop the user asked for.
|
||||
|
||||
### D. `merge-back` (bash)
|
||||
|
||||
Replaces the PR creation + review pipeline. Runs after `graph-update`:
|
||||
|
||||
```yaml
|
||||
- id: merge-back
|
||||
bash: |
|
||||
set -e
|
||||
PRIMARY=$(git worktree list | head -1 | awk '{print $1}')
|
||||
FEATURE_BRANCH=$(git branch --show-current)
|
||||
BASE="${BASE_BRANCH:-main}"
|
||||
if [ "$FEATURE_BRANCH" = "$BASE" ]; then exit 0; fi
|
||||
if [ -n "$(git -C "$PRIMARY" status --porcelain)" ]; then
|
||||
echo "ERROR: primary worktree has uncommitted changes" >&2; exit 1
|
||||
fi
|
||||
git -C "$PRIMARY" checkout "$BASE"
|
||||
git -C "$PRIMARY" merge --ff-only "$FEATURE_BRANCH" 2>/dev/null \
|
||||
|| git -C "$PRIMARY" merge --no-ff "$FEATURE_BRANCH" -m "merge: $FEATURE_BRANCH (via archon worktree)"
|
||||
depends_on: [graph-update]
|
||||
timeout: 60000
|
||||
```
|
||||
|
||||
Refuses to merge if the primary worktree has uncommitted state — surfaces the conflict rather than clobbering. Prefers fast-forward; falls back to non-ff merge for divergent histories.
|
||||
|
||||
## Skill placement
|
||||
|
||||
Skills attach to command/prompt nodes via the `skills:` array. Archon's Claude provider (`packages/providers/src/claude/provider.ts:435`) wraps the node in a `dag-node-skills` `AgentDefinition` and injects the `Skill` tool — no other changes needed.
|
||||
|
||||
Names use the namespaced form `superpowers:<skill>` since these skills are plugin-provided (not in `~/.claude/skills/`). The Claude SDK applies the standard skill-resolution chain which understands plugin namespaces. If a runtime test surfaces a "skill not found" error, the fallback is to symlink each plugin skill into `~/.claude/skills/superpowers/<name>/` and reference without the namespace.
|
||||
|
||||
### Feature workflow (`archon-graph-superpowers-feature`)
|
||||
|
||||
| Node | Skills |
|
||||
|---|---|
|
||||
| `create-plan` | `superpowers:brainstorming` |
|
||||
| `implement-tasks` | `superpowers:test-driven-development`, `superpowers:systematic-debugging` |
|
||||
| `validate` | `superpowers:verification-before-completion` |
|
||||
| `workflow-summary` | `superpowers:finishing-a-development-branch` |
|
||||
|
||||
### Issue workflow (`archon-graph-superpowers-fix-issue`)
|
||||
|
||||
| Node | Skills |
|
||||
|---|---|
|
||||
| `investigate` | `superpowers:systematic-debugging` |
|
||||
| `plan` | `superpowers:brainstorming` |
|
||||
| `implement` | `superpowers:test-driven-development`, `superpowers:systematic-debugging` |
|
||||
| `validate` | `superpowers:verification-before-completion` |
|
||||
| `report` | `superpowers:finishing-a-development-branch` |
|
||||
|
||||
Since the workflows no longer create a PR or run the PR-based review pipeline, the `archon-code-review-agent`, `archon-error-handling-agent`, `archon-test-coverage-agent`, `archon-comment-quality-agent`, `archon-docs-impact-agent`, `archon-synthesize-review`, `archon-implement-review-fixes`, `archon-self-fix-all`, and `archon-simplify-changes` commands are all dropped. The user can invoke `superpowers:requesting-code-review` manually on the local diff before the merge-back step if they want a review pass.
|
||||
|
||||
## Node ordering
|
||||
|
||||
### `archon-graph-superpowers-feature` (feature path)
|
||||
|
||||
```
|
||||
graph-health-check → graph-context → create-plan → plan-setup → confirm-plan
|
||||
→ implement-tasks → validate → graph-update → merge-back → workflow-summary
|
||||
```
|
||||
|
||||
Two new bash nodes (`graph-health-check`, `graph-context`) prepend the chain. `graph-update` and `merge-back` replace the entire PR + review pipeline.
|
||||
|
||||
### `archon-graph-superpowers-fix-issue` (issue path)
|
||||
|
||||
```
|
||||
extract-issue-number → fetch-issue → classify
|
||||
→ graph-health-check → graph-context
|
||||
→ web-research
|
||||
→ investigate (when issue_type == 'bug') | plan (else)
|
||||
→ bridge-artifacts → implement → validate → graph-update → merge-back → report
|
||||
```
|
||||
|
||||
`graph-context` runs after `classify` so the BFS has access to the issue body (already on disk from `fetch-issue`). The bash node uses `$ARGUMENTS` as the query string — the issue title and body remain available to downstream investigate/plan nodes via `$fetch-issue.output` and `$classify.output`.
|
||||
|
||||
## Error / edge handling
|
||||
|
||||
- **No `graphify-out/`**: `graph-health-check` writes `missing` state; `graph-context` writes a placeholder. Downstream nodes proceed without graph context — they just lose the upfront context boost.
|
||||
- **graphify CLI absent**: same handling — soft-skip with a warning to stderr.
|
||||
- **`graphify query` exceeds 60s**: bash node times out; workflow continues to the next phase because graph context is best-effort, not required.
|
||||
- **`graphify . --update` exceeds 120s**: workflow fails the bash node. Retry config (`retry: max_attempts: 2, on_error: all`) covers transient FS issues. The hard fail is intentional — a partial graph that gets merged back to base is worse than no refresh.
|
||||
- **Empty diff after `graphify . --update`**: no commit is created; merge-back is unaffected.
|
||||
- **Primary worktree dirty at merge-back time**: `merge-back` refuses and prints the manual merge command. The work is preserved in the worktree's branch — the user resolves the dirty state and runs the merge by hand. No data loss.
|
||||
- **Non-fast-forward merge**: `merge-back` falls back to `--no-ff` with an explicit merge commit message, preserving the worktree's branch history.
|
||||
- **Worktree isolation**: Archon runs each workflow in its own git worktree by default. `graphify-out/` is per-worktree, so concurrent runs of these workflows don't trample each other's graph files. The primary worktree is shared — concurrent merge-backs serialize via git's index lock.
|
||||
|
||||
## Validation plan
|
||||
|
||||
1. `archon workflow list` reports both `archon-graph-superpowers-feature` and `archon-graph-superpowers-fix-issue` from the project directory.
|
||||
2. `archon workflow validate archon-graph-superpowers-feature` (if the CLI exposes a validate subcommand) — otherwise rely on `archon workflow list` exit code which loads and parses every YAML.
|
||||
3. Dry-run `archon-graph-superpowers-feature` against a trivial idea (e.g., "Add a comment to main.ts explaining the LangChain exception for Anthropic") and confirm: graph context artifact written, plan references it, graph refresh fires, primary worktree's `main` advances after merge-back.
|
||||
4. Dry-run `archon-graph-superpowers-fix-issue` against an existing labeled issue with a known easy fix; same checks.
|
||||
5. Verify cleanup pattern: after a run completes, `archon complete <branch>` removes the worktree and the branch (commits are preserved on `main` from the merge-back).
|
||||
|
||||
## Open risks
|
||||
|
||||
1. **Skill namespace resolution** — if `superpowers:test-driven-development` doesn't resolve through the Claude SDK skill chain inside an Archon agent, the symlink fallback adds 14 symlinks under `~/.claude/skills/superpowers/`. Detectable at workflow runtime by a "skill not found" log; fallback is mechanical.
|
||||
2. **`graphify check-update`** is advisory — TubeSage's graph might be marked as needing semantic re-extraction (which costs API credits) and we're silently ignoring that. Acceptable for the AST-only refresh path; if the graph drifts substantially, the user runs `/graphify` manually.
|
||||
3. **Cost of `graphify query` per workflow run** — query is local BFS, no LLM cost. Safe to run on every invocation.
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
# API Keys via Obsidian Secret Storage — Design
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Status:** Approved (Option A — pure secret storage)
|
||||
|
||||
## Goal
|
||||
|
||||
Move TubeSage's cloud-provider API keys out of the plugin's `data.json` and into
|
||||
Obsidian's native `app.secretStorage`. This removes plaintext API keys from a
|
||||
file that syncs via iCloud, and offloads the responsibility of securing secrets
|
||||
from TubeSage to Obsidian itself.
|
||||
|
||||
## Background
|
||||
|
||||
Today, all provider credentials live in `settings.apiKeys: Record<string, string>`,
|
||||
persisted to `.obsidian/plugins/tubesage/data.json`. That file is inside the vault,
|
||||
so it syncs (iCloud Drive / Obsidian Sync) as plaintext.
|
||||
|
||||
Obsidian 1.11.4 introduced `app.secretStorage`:
|
||||
|
||||
- `setSecret(id: string, secret: string): void` — synchronous
|
||||
- `getSecret(id: string): string | null` — synchronous
|
||||
- `listSecrets(): string[]`
|
||||
- No delete method.
|
||||
- Secrets are stored in Obsidian's application **localStorage**, keyed to the vault.
|
||||
This is **device-local and per-vault** — it does NOT sync between devices, and it
|
||||
is NOT the OS keychain.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Option A — pure secret storage.** No hybrid/fallback. `minAppVersion` is raised
|
||||
to `1.11.4`; users on older Obsidian are not supported.
|
||||
- **Keys do not sync.** Accepted: the user re-enters each provider key once per
|
||||
vault/device. The security gain (keys out of synced plaintext) is the point.
|
||||
- **Ollama is excluded.** `apiKeys['ollama']` holds the Ollama *server URL*
|
||||
(`http://localhost:11434`), not a secret. It stays in plugin settings.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — move to secret storage: **OpenAI, Anthropic, Google, OpenRouter** keys.
|
||||
Out of scope: Ollama (URL, not a secret); the YouTube Data API key is not part of
|
||||
`settings.apiKeys` and is unchanged by this work.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Secret IDs
|
||||
|
||||
Fixed, deterministic, one per cloud provider. IDs are lowercase alphanumeric with
|
||||
dashes (required by `setSecret`):
|
||||
|
||||
| Provider | Secret ID |
|
||||
|------------|----------------------------|
|
||||
| openai | `tubesage-openai-key` |
|
||||
| anthropic | `tubesage-anthropic-key` |
|
||||
| google | `tubesage-google-key` |
|
||||
| openrouter | `tubesage-openrouter-key` |
|
||||
|
||||
A single source-of-truth map (e.g. `SECRET_IDS: Record<CloudProvider, string>`)
|
||||
defines these. No secret IDs are stored in `data.json` — they are derived from the
|
||||
provider name.
|
||||
|
||||
### 2. Settings model — `apiKeys` becomes a runtime field
|
||||
|
||||
`settings.apiKeys: Record<string, string>` is **kept**, but its persistence role changes:
|
||||
|
||||
- The 4 cloud-provider entries (`openai`, `anthropic`, `google`, `openrouter`) become
|
||||
**runtime-only**: populated from `secretStorage` on load, never written to `data.json`.
|
||||
- The `ollama` entry (the server URL) continues to persist in `data.json` normally —
|
||||
it is not a secret. No rename, no new fields.
|
||||
|
||||
Rationale: keeping `settings.apiKeys` as a live runtime object means the ~25 existing
|
||||
sites that read `this.settings.apiKeys[provider]` (including several debug-logging
|
||||
blocks that iterate the whole object) keep working unchanged. The blast radius drops
|
||||
from ~25 sites to ~3.
|
||||
|
||||
### 3. Load / save behavior
|
||||
|
||||
- **`loadSettings()`**: after the existing `{...DEFAULT_SETTINGS, ...loadedSettings}`
|
||||
merge, run the one-time migration (section 6), then overwrite
|
||||
`settings.apiKeys[provider]` for each cloud provider with
|
||||
`app.secretStorage.getSecret(SECRET_IDS[provider]) ?? ''`.
|
||||
- **`saveSettings()` / `saveData()`**: persist a sanitized clone of `settings` whose
|
||||
`apiKeys` contains only the `ollama` entry — the 4 cloud keys are stripped before the
|
||||
write, so `data.json` never holds them.
|
||||
- The secret-storage API is synchronous, so no async refactor is needed anywhere.
|
||||
|
||||
### 4. Blast radius
|
||||
|
||||
Because `settings.apiKeys` still exists at runtime and resolves cloud keys (from secret
|
||||
storage) exactly as before, the ~25 existing read sites are **unchanged**. Only three
|
||||
areas change: `loadSettings()`, `saveSettings()`, and the API-key settings UI.
|
||||
`transcript-summarizer.ts` and `llm-factory.ts` are untouched — they keep receiving the
|
||||
`apiKeys` object built in `main.ts`, which now resolves cloud keys from secret storage.
|
||||
|
||||
### 5. Settings UI
|
||||
|
||||
The 4 cloud-provider key fields keep their current plain text-input UX (the existing
|
||||
`.addText()` controls in `createProviderApiKeyRow`). They are NOT converted to
|
||||
Obsidian's `SecretComponent` — that component is designed for selecting/sharing named
|
||||
secrets across plugins, which is heavier than TubeSage needs.
|
||||
|
||||
- On render: `setValue(this.plugin.settings.apiKeys[provider])` (unchanged — the
|
||||
in-memory value was populated from secret storage at load).
|
||||
- On change (cloud provider): write `app.secretStorage.setSecret(SECRET_IDS[provider],
|
||||
value)` AND update the in-memory `settings.apiKeys[provider]`. Do NOT rely on
|
||||
`saveSettings()` to persist the key (it strips cloud keys).
|
||||
- On change (`ollama`): write `settings.apiKeys['ollama']` and `saveSettings()` as today.
|
||||
|
||||
The field's existing description text ("Stored in plugin data; ...") is updated to
|
||||
reflect that cloud keys are stored in Obsidian's secret storage.
|
||||
|
||||
### 6. Migration
|
||||
|
||||
One-time, in `onload()` after settings are loaded. For each cloud provider:
|
||||
|
||||
```
|
||||
if data.json still has a non-empty apiKeys[provider]
|
||||
and secretStorage.getSecret(SECRET_IDS[provider]) is null/empty:
|
||||
secretStorage.setSecret(SECRET_IDS[provider], oldKey)
|
||||
delete the cloud-provider entries from the loaded settings object
|
||||
persist settings (saveSettings) so data.json no longer contains the keys
|
||||
```
|
||||
|
||||
This copies any pre-existing keys into secret storage and scrubs them from the
|
||||
plaintext `data.json` (the scrub happens because `saveSettings()` now strips cloud
|
||||
keys). It is idempotent — on later loads there is nothing to migrate. The Ollama
|
||||
entry is untouched — it stays in `apiKeys['ollama']` and continues to persist.
|
||||
|
||||
### 7. Version bumps
|
||||
|
||||
- `manifest.json`: `minAppVersion` `1.2.0` → `1.11.4`
|
||||
- `package.json`: obsidian devDependency `^1.8.7` → `^1.11.4`
|
||||
(the installed types are already 1.12.3 and contain `SecretStorage` / `secretStorage`,
|
||||
so no behavioral install change — this just makes the floor explicit)
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- `getSecret` returns `null` when a key was never set → `getApiKey` returns `''`,
|
||||
treated everywhere as "no key configured" (same as today's empty string).
|
||||
- No delete API — "clearing" a key is `setSecret(id, '')`. An empty string is the
|
||||
canonical "unset" value.
|
||||
- If `app.secretStorage` is somehow unavailable at runtime (should not happen given
|
||||
`minAppVersion` 1.11.4), `getApiKey` returns `''` rather than throwing.
|
||||
- Mobile: `secretStorage` is part of the 1.11.4 API on all platforms; localStorage
|
||||
exists in the mobile webview. No platform branching needed.
|
||||
|
||||
## Testing
|
||||
|
||||
No automated test harness exists in the project. Verification is build + manual:
|
||||
|
||||
1. `npm run build` succeeds (TypeScript + esbuild).
|
||||
2. Fresh install: enter an OpenAI key in settings → confirm it appears via
|
||||
`secretStorage` and is absent from `data.json`.
|
||||
3. Restart Obsidian → the key still resolves (persisted).
|
||||
4. Run a summary with that provider → authentication succeeds.
|
||||
5. Upgrade path: start from a `data.json` containing `apiKeys` with a key →
|
||||
after load, the key is in secret storage and gone from `data.json`; Ollama URL
|
||||
moved to `settings.ollamaUrl`.
|
||||
|
||||
## Out of scope / non-goals
|
||||
|
||||
- No hybrid/fallback for Obsidian < 1.11.4.
|
||||
- No change to Ollama handling — its URL stays in `apiKeys['ollama']`.
|
||||
- No `SecretComponent` UI.
|
||||
- No change to the YouTube Data API key.
|
||||
- No `getApiKey()`/`setApiKey()` accessor — keeping `settings.apiKeys` as a runtime
|
||||
field makes one unnecessary.
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# Reduce vault-enumeration and clipboard surface — design
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Clear two flags on the Obsidian community-plugin scorecard for TubeSage:
|
||||
|
||||
- **Vault Enumeration** — "Enumerates all files in the vault (`vault.getFiles`, `getMarkdownFiles`, etc.)."
|
||||
- **Clipboard Access** — "Reads or writes the system clipboard."
|
||||
|
||||
The plugin's user-facing behavior must not change: the template picker and the folder picker keep working exactly as before.
|
||||
|
||||
## Background
|
||||
|
||||
The scorecard scanner flags any use of whole-vault enumeration APIs and any use of `navigator.clipboard`. TubeSage triggers both:
|
||||
|
||||
- Vault enumeration at three call sites in `main.ts`:
|
||||
- `~1706` — a diagnostic block that calls `getMarkdownFiles()` only to `logger.debug` files near a not-found note.
|
||||
- `~5320` — the template picker modal: `getMarkdownFiles()` over the whole vault, then filters to the templates folder.
|
||||
- `~5457` — the folder picker (`loadFolders`): `getAllLoadedFiles()` over the whole vault, then filters to folders under the configured root folder.
|
||||
- Clipboard at one call site: `main.ts:~6454`, `navigator.clipboard.writeText(templateContent)` behind the "Copy template" button in the template-viewer modal.
|
||||
|
||||
There is no cross-platform clipboard API that avoids `navigator.clipboard`, and no way to obtain a folder/file list without an enumeration API. So the flags can only be cleared by removing the clipboard feature and by scoping enumeration to specific folder subtrees rather than the whole vault.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Remove the "Copy template" button (clipboard)
|
||||
|
||||
In the template-viewer modal (`main.ts`, around line 6454), remove the "Copy template" button, its container element, and the `navigator.clipboard.writeText(...)` call and its handler. The template text remains visible in the modal and can still be selected and copied manually with Cmd/Ctrl+C.
|
||||
|
||||
After this change, `grep -rn "navigator.clipboard\|clipboard" main.ts src/` returns nothing.
|
||||
|
||||
### 2. Delete the diagnostic enumeration block (`main.ts:~1706`)
|
||||
|
||||
In the not-found error branch (the block beginning around `main.ts:1702` with the comment "Try to check if any similar files exist"), delete the `getMarkdownFiles()` call and the surrounding logging of "files in the same folder." The `logger.error` that reports the missing file path stays. This block only produced a debug log line; removing it has no functional effect.
|
||||
|
||||
### 3. Scoped folder-walk helper
|
||||
|
||||
Add one helper to `src/utils/path-utils.ts`:
|
||||
|
||||
```ts
|
||||
import { TFolder, TFile, Vault } from "obsidian";
|
||||
|
||||
/**
|
||||
* Collect descendants of a folder by walking TFolder.children, without
|
||||
* enumerating the whole vault. Returns [] if the path is not a folder.
|
||||
* `kind` selects folders or markdown files; the start folder itself is
|
||||
* included when kind is "folder".
|
||||
*/
|
||||
export function collectUnder(
|
||||
vault: Vault,
|
||||
folderPath: string,
|
||||
kind: "folder" | "markdown",
|
||||
): Array<TFolder | TFile>
|
||||
```
|
||||
|
||||
Implementation: `vault.getAbstractFileByPath(folderPath)`; if it is not a `TFolder`, return `[]`. Otherwise walk `children` recursively (iteratively, to avoid deep recursion), collecting `TFolder`s (for `"folder"`, including the start folder) or `TFile`s whose extension is `md` (for `"markdown"`).
|
||||
|
||||
This touches only the named subtree, never the whole vault.
|
||||
|
||||
### 4. Template picker uses the helper (`main.ts:~5320`)
|
||||
|
||||
Replace `this.app.vault.getMarkdownFiles()` + the templates-folder filter with `collectUnder(this.app.vault, this.templatesFolder, "markdown")`. The resulting list is the markdown files under the configured templates folder.
|
||||
|
||||
Behavior note: the current filter also loosely matches a templates folder nested anywhere in the vault (`path.includes('/' + templatesFolder + '/')`). The helper resolves the exact configured `templatesFolder` path instead. This is a deliberate correctness tightening: the templates folder is a configured path and should be matched as such. Not treated as a regression.
|
||||
|
||||
### 5. Folder picker uses the helper (`main.ts:~5457`)
|
||||
|
||||
Replace `this.app.vault.getAllLoadedFiles()` + the under-root filter with `collectUnder(this.app.vault, rootFolder, "folder")`. The root folder is still added explicitly first (existing behavior); the helper supplies its descendant folders. The existing dedupe via `uniquePaths` stays.
|
||||
|
||||
## Result
|
||||
|
||||
`grep` for `getFiles`, `getMarkdownFiles`, `getAllLoadedFiles`, `getAllFiles`, and `navigator.clipboard` across `main.ts` and `src/` returns nothing. Both pickers list the same entries as before. The scanner has no vault-enumeration or clipboard API surface to flag.
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
- `collectUnder` returns `[]` when the path does not exist or is not a folder. The template picker then shows no templates (same as today when the folder is empty or missing); the folder picker still has the explicitly-added root folder.
|
||||
- The folder picker already ensures the root folder exists (`ensureFolder`) before walking; that stays.
|
||||
- `getAbstractFileByPath` is a targeted lookup, already used widely in `main.ts`, and is not an enumeration API.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit-test framework exists in this project; verification is:
|
||||
|
||||
- `npm run build` — clean (tsc + esbuild).
|
||||
- `npm run lint` — clean (0 errors, 0 warnings).
|
||||
- `grep` checks above return nothing.
|
||||
- Manual: open the template picker and confirm it lists the template files under the configured templates folder; open the create-note folder picker and confirm it lists the folders under the root folder; confirm the template-viewer modal still shows template text (just without the Copy button).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The other scorecard items (`atob`/`btoa` in bundled dependencies, vault read/write, the "scan not available" lines) are not addressed here; they are either inherent functionality or not plugin-fixable.
|
||||
- No release is performed by this work; releasing an updated build is a separate decision.
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
# Restore copy button, fix info-tooltips, refresh diagrams — design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Three independent improvements to the TubeSage plugin, plus a documented assessment:
|
||||
|
||||
1. **Restore the "Copy template" button** in the template-viewer modal. On mobile the template example cannot be scrolled, so users have no way to read it; the copy button lets them get the text out. Also make a best-effort fix to mobile touch-scrolling of that modal.
|
||||
2. **Fix the settings info (ⓘ) tooltips.** They currently fail to render reliably on hover. Replace the hand-rolled tooltip mechanism with Obsidian's native `setTooltip()`. Add an OpenRouter reliability note to the LLM tooltip.
|
||||
3. **Refresh the mermaid diagrams** (`docs/workflow-diagram.md`, `docs/data-flow-diagram.md`) to match the current architecture.
|
||||
4. **Scorecard disclosures** — assessment only, no code (see end).
|
||||
|
||||
## Background
|
||||
|
||||
### Part 1 — copy button
|
||||
The "Copy template" button was removed in commits `d658ae21` (markup in `main.ts`) and `3710f47f` (CSS in `styles.css`) as part of the 1.3.2 vault/clipboard surface reduction. Removing it cleared the scorecard "Clipboard Access" flag, but on Obsidian mobile the template-viewer's scroll box cannot be scrolled by touch, leaving mobile users unable to read the example template. The button is being restored deliberately; the returning "Clipboard Access" disclosure is an accepted trade-off.
|
||||
|
||||
### Part 2 — info tooltips
|
||||
`main.ts` has a private helper `createInfoIcon(container, tooltipText)` used in exactly three places — the *Transcript settings*, *LLM*, and *Advanced* section headings. Each call passes full tooltip text; the text is present in the source and in the deployed 1.3.2 build. The helper builds the tooltip three ways:
|
||||
- a `data-tooltip` attribute plus a CSS `::after { content: attr(data-tooltip) }` rule shown on `:hover` (custom, should be instant),
|
||||
- a raw `title` attribute (browser-native, slow),
|
||||
- a `cursor: help` style.
|
||||
|
||||
Observed behavior: on hover the `help` cursor (a question mark) shows instantly; 2-3 seconds later the browser `title` tooltip sometimes appears. The custom `::after` tooltip is flaky/not rendering. The custom mechanism also does nothing on mobile (no hover on touch).
|
||||
|
||||
The LLM heading tooltip currently ends: *"Suggested for most users: Google provider with the gemini-2.5-flash model — fast, inexpensive, and high-quality."*
|
||||
|
||||
### Part 3 — diagrams
|
||||
`docs/workflow-diagram.md` and `docs/data-flow-diagram.md` contain themed mermaid flowcharts. They predate recent changes: API keys now use Obsidian secret storage, the UI uses native components, folder access is scoped, LangChain's tiktoken helper is stubbed, and clipboard/enumeration surface was reduced.
|
||||
|
||||
## Changes
|
||||
|
||||
### Part 1: Restore the copy button
|
||||
|
||||
In `main.ts`, in the template-viewer modal, restore the copy-button block that `d658ae21` removed: the `copyContainer` div, the "Copy template" label span, the `copyButton` with its copy-icon SVG, the hover handlers, and the `handleCopy` function that calls `navigator.clipboard.writeText(templateContent)` with success/error feedback on the label. It is placed where it was before — associated with the template-viewer's content area.
|
||||
|
||||
In `styles.css`, restore the two rules `3710f47f` removed: `.tubesage-template-view-copy-container` and `.tubesage-template-view-copy-text`. Use the current spacing-variable conventions (`var(--size-4-*)`, `var(--radius-*)`) consistent with the rest of `styles.css`.
|
||||
|
||||
`git revert d658ae21 3710f47f` is the expected mechanism; the implementer should confirm the reverts apply cleanly and, if not, re-apply the block by hand from those commits' diffs.
|
||||
|
||||
Best-effort mobile-scroll fix: add `-webkit-overflow-scrolling: touch;` to the scrollable template/license/readme container rules (`.tubesage-template-view-container`, `.tubesage-license-container`, `.tubesage-readme-container`). This is the standard momentum-scroll enabler for the mobile webview. If it does not fully resolve mobile scrolling, that is a deeper investigation out of scope here — the copy button is the guaranteed path and the primary deliverable of Part 1.
|
||||
|
||||
### Part 2: Native tooltips for info icons
|
||||
|
||||
In `main.ts`:
|
||||
- Add `setTooltip` to the `import { ... } from 'obsidian'` line.
|
||||
- In `createInfoIcon`, replace the three-way custom tooltip with a single call: `setTooltip(infoIcon, tooltipText, { placement: 'bottom' })`. Remove the `data-tooltip` `setAttribute`, the `addClass('tubesage-settings-info-icon-with-tooltip')`, and the raw `setAttr('title', tooltipText)`.
|
||||
- Keep the `.tubesage-settings-info-icon` class on the span (icon styling, including `cursor: help`).
|
||||
- Append to the LLM heading tooltip text: `" If you hit rate limits or reliability issues, OpenRouter is a solid fallback."`
|
||||
|
||||
In `styles.css`:
|
||||
- Remove the now-unused rules: `.tubesage-settings-info-icon-with-tooltip` and `.tubesage-settings-info-icon-with-tooltip::after` and `.tubesage-settings-info-icon-with-tooltip:hover::after`.
|
||||
- Keep `.tubesage-settings-info-icon`.
|
||||
|
||||
Result: the three info tooltips render via Obsidian's native tooltip — fast, reliably, correctly positioned, and functional on mobile. This is the same native mechanism the plugin already uses for button tooltips (`.setTooltip(...)`).
|
||||
|
||||
Scope note: there are three info icons and there always have been (since v1.2.21). This change fixes those three; it does not add info icons to additional settings. If broader coverage is wanted, that is a separate additive change.
|
||||
|
||||
### Part 3: Refresh the mermaid diagrams
|
||||
|
||||
Run graphify over the repository to produce current dependency/community/structure output as factual input. Then update the mermaid flowcharts in `docs/workflow-diagram.md` and `docs/data-flow-diagram.md` so they reflect the current architecture, specifically:
|
||||
- API keys stored in Obsidian secret storage (cloud providers), not `data.json`.
|
||||
- Native Obsidian UI components in the modal and settings.
|
||||
- Scoped folder traversal (`collectUnder`) rather than whole-vault enumeration.
|
||||
- LangChain tiktoken helper stubbed (no `tiktoken.pages.dev` request).
|
||||
- Providers: OpenAI, Anthropic, Google, OpenRouter (LangChain), Ollama (local).
|
||||
|
||||
Keep the existing mermaid theme/init block and the documents' prose structure; update node/edge content only. Preserve mermaid syntax validity.
|
||||
|
||||
## Out of scope / assessment
|
||||
|
||||
### Part 4: Scorecard disclosures (no code)
|
||||
- `tiktoken.pages.dev` — already eliminated in 1.3.1 (build-time stub). A re-scan of 1.3.2+ will not show it.
|
||||
- Vault Enumeration — already eliminated in 1.3.2.
|
||||
- Clipboard Access — returns with Part 1; accepted trade-off (mobile readability outweighs the disclosure).
|
||||
- `atob()/btoa()` and the `fetch`/`request` counts — inside bundled dependencies (LangChain, zod); not removable without dropping those libraries. Disclosed in the README.
|
||||
- "Malware/Obfuscation/Network scan not available" — Obsidian-side tooling; nothing in the plugin to change.
|
||||
|
||||
No code changes for Part 4.
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
- Copy button: `navigator.clipboard.writeText` returns a promise; the restored `handleCopy` keeps its existing `.then`/`.catch` that shows "Copied" / "Failed to copy" feedback on the label.
|
||||
- `setTooltip` on an element is a no-op visually until hover; passing the full (possibly long) text is fine — Obsidian wraps native tooltips.
|
||||
- Removing the custom tooltip CSS while a `data-tooltip` attribute lingered would be harmless, but the attribute is also removed, so no dead attribute remains.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit-test framework exists; verification is:
|
||||
- `npm run build` clean (tsc + esbuild).
|
||||
- `npm run lint` clean (0 errors, 0 warnings).
|
||||
- `grep` confirms `data-tooltip` and `tubesage-settings-info-icon-with-tooltip` are gone from `main.ts` and `styles.css`.
|
||||
- Manual in a dev vault: the template viewer shows the Copy button and it copies; the three settings info icons show their tooltip promptly on hover (desktop) and on tap (mobile); the LLM tooltip includes the OpenRouter note.
|
||||
- Mermaid: the two diagram files render without syntax errors.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Mobile template-viewer height fix — design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
On Obsidian mobile, the template-viewer modal (`TemplateViewModal`, which shows the example Templater template) collapses so its content area is a single blank line — the template appears to be missing. Make the mobile template-viewer large enough that a real portion of the template is visible and scrollable, as it is on desktop.
|
||||
|
||||
## Background
|
||||
|
||||
`.tubesage-template-view-container` in `styles.css` (line 648) sets only `max-height` (500px, narrowed to 250px by the `tubesage-template-view-container-short` modifier the modal also applies) plus `overflow-y: auto` and `-webkit-overflow-scrolling: touch`. `max-height` only caps height; the box's actual height is the content's intrinsic height. On desktop the `<pre>` establishes a normal height, so the box fills to it. On mobile the box collapses to roughly one blank line.
|
||||
|
||||
Separately, `.tubesage-template-view-modal-size.modal` (line 638) has no entry in the `@media (max-width: 768px)` block, unlike the license and readme modals, which get explicit mobile size overrides there.
|
||||
|
||||
## Change
|
||||
|
||||
Add two rules to the existing `@media (max-width: 768px)` block in `styles.css`:
|
||||
|
||||
1. `.tubesage-template-view-modal-size.modal` — mobile size override, matching the license/readme pattern already in that block:
|
||||
```css
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
```
|
||||
|
||||
2. `.tubesage-template-view-container` — a definite height for mobile (overrides the desktop `max-height`):
|
||||
```css
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
```
|
||||
A definite `height` (not just `max-height`) forces the box to a usable size regardless of the content's intrinsic height, so a portion of the template is always shown. The existing `overflow-y: auto` and `-webkit-overflow-scrolling: touch` make the rest scrollable.
|
||||
|
||||
CSS only. No TypeScript change, no behavior change. Desktop styling is untouched — the new rules live only inside the `@media (max-width: 768px)` block.
|
||||
|
||||
## Out of scope
|
||||
|
||||
No change to the desktop template viewer, the modal contents, or any other modal.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit-test framework. Verification:
|
||||
- `npm run build` clean (the CSS is not built, but confirm nothing broke).
|
||||
- Manual on Obsidian mobile (or a narrow window): open the template viewer; the modal is ~95vw, the template content area is ~50vh tall, a portion of the template is visible, and it scrolls. Desktop unchanged.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# Service info icons, OpenRouter model refresh, desktop spinner fix — design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Three independent improvements to the TubeSage plugin, batched for the 1.3.4 release:
|
||||
|
||||
1. Add info (ⓘ) hover icons showing the service URL on the ScrapeCreators, Supadata, and OpenRouter settings.
|
||||
2. Add an OpenRouter model-list refresh, paralleling the existing OpenAI/Google/Anthropic refresh, that pulls the full live model list and updates per-model token limits.
|
||||
3. Fix a desktop bug where the processing spinner leaves a blank popup; on desktop the status bar is the spinner surface and no popup should appear.
|
||||
|
||||
## Background
|
||||
|
||||
- **Item 1.** The plugin has a `createInfoIcon(container, tooltipText)` helper (native `setTooltip` on hover), used today on three settings section headings. The ScrapeCreators API-key setting (`main.ts` ~3977) and Supadata API-key setting (~4005) have no info icon; neither does the OpenRouter provider/key setting. Their service URLs are `https://scrapecreators.com/`, `https://supadata.ai/`, `https://openrouter.ai/`.
|
||||
- **Item 2.** The plugin has `fetchOpenAIModels`, `fetchGoogleModels`, `fetchAnthropicModels` and a per-provider refresh button in `createProviderConfigBlock` (~`main.ts:4268`) whose `onClick` switches on the provider. There is **no** `fetchOpenRouterModels` and no `openrouter` case in that switch; `settings.fetchedModels.openrouter` is always `[]`, so the OpenRouter model dropdown only has a minimal default. `fetchGoogleModels`/`fetchAnthropicModels` also store per-model token limits into the model-limits registry. OpenRouter publishes its full model list at `https://openrouter.ai/api/v1/models` — a public endpoint requiring no API key — with per-model `id`, `name`, `context_length`, and `top_provider` (which includes a max-output-tokens figure).
|
||||
- **Item 3.** `beginCollectionProcessing` (`main.ts:3120`) and the single-video processing method (~3445) keep the create-note modal (`YouTubeTranscriptModal`) open and reuse it as a processing popup: they `contentEl.empty()`, add the `tubesage-processing-modal` class to `modalEl`, and create a `ProcessingSpinner`. `ProcessingSpinner.start()` renders to the **status bar on desktop** and to an **in-modal element on mobile**. So on desktop the modal is left blank — a popup with no content. `YouTubeTranscriptModal.onClose()` only calls `contentEl.empty()`; it does not touch `isProcessing` or abort anything, so closing the modal does not interrupt the async processing run.
|
||||
|
||||
## Changes
|
||||
|
||||
### Item 1 — Service info icons
|
||||
|
||||
For each of the three settings, after the `Setting` is built, get its name element (`setting.settingEl.querySelector('.setting-item-name')`, guard it is an `HTMLElement`) and call `createInfoIcon(nameEl, url)` with:
|
||||
- ScrapeCreators API-key setting → `https://scrapecreators.com/`
|
||||
- Supadata API-key setting → `https://supadata.ai/`
|
||||
- OpenRouter setting → `https://openrouter.ai/`
|
||||
|
||||
This matches exactly how the three existing section-heading info icons are attached. The tooltip shows the URL as plain hover text (a `setTooltip` tooltip is not clickable; that is acceptable and what was requested).
|
||||
|
||||
### Item 2 — OpenRouter model refresh
|
||||
|
||||
Add a `fetchOpenRouterModels()` method on the plugin, modelled on `fetchGoogleModels`:
|
||||
- Fetch `https://openrouter.ai/api/v1/models` via the cross-platform `obsidianFetch` shim. No API key is sent or required (the endpoint is public).
|
||||
- Parse the response: each entry has `id` (e.g. `openai/gpt-4o`), `context_length`, and `top_provider.max_completion_tokens` (max output tokens; may be absent for some models).
|
||||
- Store the model id list into `settings.fetchedModels.openrouter` and persist settings.
|
||||
- For each model with usable limits, update the model-limits registry via the same `upsertModel` path `fetchGoogleModels`/`fetchAnthropicModels` use, recording the context window and max output tokens.
|
||||
- Show the same user-facing notices the other fetchers show ("Fetching OpenRouter models...", success count, error message on failure).
|
||||
|
||||
Wire it into the refresh button: add an `openrouter` case to the provider switch in `createProviderConfigBlock` (~`main.ts:4280`) that calls `fetchOpenRouterModels()`. Unlike the other providers, the OpenRouter refresh does not require the API key to be present (the models endpoint is public) — the case must not early-return on a missing key.
|
||||
|
||||
Dropdown population: the OpenRouter model dropdown is rendered with the **full** fetched list, **sorted by vendor then model id**, and **grouped under `<optgroup>` headers per vendor** (the segment before the `/` in the model id — `openai`, `anthropic`, `google`, `meta-llama`, …). Because Obsidian's `DropdownComponent.addOption` is flat, the `<optgroup>` elements are appended directly to the component's `.selectEl`. The currently-selected model stays selected after a refresh if it is still in the list.
|
||||
|
||||
### Item 3 — Desktop spinner: no blank popup
|
||||
|
||||
In both `beginCollectionProcessing` and the single-video processing method, gate the modal-as-popup behavior on platform:
|
||||
- **Desktop** (`!Platform.isMobile`): close the create-note modal at the start of processing (`this.close()`), before/around creating the `ProcessingSpinner`. The status-bar spinner and Notices are the desktop UX; no popup. Closing the modal is safe — `onClose()` only empties `contentEl` and does not abort the async run.
|
||||
- **Mobile** (`Platform.isMobile`): keep the current behavior — empty `contentEl`, add the `tubesage-processing-modal` class, render the in-modal spinner.
|
||||
|
||||
`ProcessingSpinner` is still created and started in both cases; it already routes itself to the status bar on desktop and to the in-modal element on mobile. The `tubesage-processing-modal` class and the `contentEl.empty()` only apply on mobile now.
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
- `fetchOpenRouterModels`: on network/parse failure, show an error Notice and leave `fetchedModels.openrouter` unchanged (do not wipe an existing list). Mirror the existing fetchers' try/catch.
|
||||
- Models lacking `top_provider.max_completion_tokens`: still list the model; just skip the max-output registry entry for it (record context length if present).
|
||||
- Item 3: if `this.close()` is called on desktop, the async processing continues; the spinner (status bar) and Notices report progress. Confirm no later code path assumes the modal's `contentEl` is still mounted on desktop during processing.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No change to the OpenAI/Google/Anthropic fetchers or their refresh behavior.
|
||||
- No change to the mobile processing modal.
|
||||
- The deferred UI items (#1 MarkdownRenderer, #3 FuzzySuggestModal pickers) remain deferred.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit-test framework exists; verification is:
|
||||
- `npm run build` clean (tsc + esbuild), `npm run lint` clean (0/0).
|
||||
- Manual in a dev vault:
|
||||
- The ScrapeCreators, Supadata, and OpenRouter settings show an info icon; hovering shows the respective URL.
|
||||
- With OpenRouter selected, the refresh button fetches the live model list; the dropdown shows the full list grouped by vendor; token limits update.
|
||||
- On desktop, starting collection/single-video processing closes the create-note modal — no blank popup — and the status-bar spinner animates with Notices.
|
||||
- On mobile, the in-modal spinner still appears and works.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# Tier A native-component migration — design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace hand-rolled UI primitives in TubeSage's modals and settings tab with native Obsidian components, with **no intended change to look and feel**. The bespoke pieces were already styled to mimic native Obsidian UI, so the native components render essentially the same. The win is removing hand-written SVG construction and bespoke markup plus the CSS that propped it up.
|
||||
|
||||
This is "Tier A" of a larger UI audit. Two further findings — replacing the hand-rolled markdown renderer with `MarkdownRenderer` (#1) and the custom picker modals with `FuzzySuggestModal` (#3) — visibly change appearance and are **deliberately deferred**; they are out of scope here.
|
||||
|
||||
The 1.3.3 release is the "before" reference point for auditioning the result.
|
||||
|
||||
## Background
|
||||
|
||||
A UI audit of `main.ts` (8 modal/settings classes) and `styles.css` found bespoke UI where native Obsidian APIs exist. Tier A is the subset with negligible visual impact:
|
||||
|
||||
- **Hand-built SVG icons** — 4 icons constructed element-by-element with `createElementNS`: an eye icon for the license-view button (~`main.ts:3798`) and the README-view button (~`3868`), the info icon in `createInfoIcon` (~`4980`), and the copy icon in the template-viewer copy button (~`6358`).
|
||||
- **Raw `<button>` elements** — 9 buttons created with `createEl('button')`: the create-note modal Process button (~`2925`), the license-view and README-view icon buttons (~`3792`, `3862`), three modal Close buttons (~`5756`, `6089`, `6474`), and the LicenseRequiredModal's Open-settings and Close buttons (~`5854`, `5860`).
|
||||
- **Raw inputs in the create-note modal** — `YouTubeTranscriptModal` builds raw `<input type=text>` for the URL and title fields and a raw `<select>` for the video-count dropdown.
|
||||
|
||||
Native equivalents already used elsewhere in the codebase: `setIcon` (~10 uses), `ExtraButtonComponent` (1 use), `TextComponent` and `DropdownComponent` (settings tab). `ButtonComponent` is not yet used and will be added to the `obsidian` import.
|
||||
|
||||
## Changes
|
||||
|
||||
### Piece 1 — Icons via `setIcon`
|
||||
|
||||
Replace the 4 hand-built SVGs with `setIcon(el, name)` (Lucide icon set, Obsidian's built-in):
|
||||
- the two eye icons → `setIcon(el, 'eye')`
|
||||
- the info icon in `createInfoIcon` → `setIcon(el, 'info')` (the info `<span>` stays a span; only the SVG construction is replaced; the existing `setTooltip` call stays)
|
||||
- the copy icon → `setIcon(el, 'copy')`
|
||||
|
||||
Remove the `createElementNS` SVG-building blocks and any now-unused `svgNamespace` constants local to those blocks.
|
||||
|
||||
Note: the three eye/copy icons live on buttons that Piece 2 converts to `ExtraButtonComponent`, which sets its icon via its own `.setIcon()`. So in practice Piece 1's eye/copy work is subsumed by Piece 2; the standalone `setIcon` change there applies only if a button is not converted. The info icon is the one genuinely standalone `setIcon` change (it is not a button).
|
||||
|
||||
### Piece 2 — Buttons via `ButtonComponent` / `ExtraButtonComponent`
|
||||
|
||||
- **Icon buttons** (license-view eye, README-view eye, template-viewer copy) → `ExtraButtonComponent`. Each call sets `.setIcon('eye' | 'copy')`, `.setTooltip(...)` (preserving the current tooltip text), and `.onClick(...)` (preserving the current click behavior). This replaces the raw `<button>`, the hand-built SVG, the manual hover handlers, and the tooltip in one component.
|
||||
- **Text buttons** (Process, the three Close buttons, Open settings) → `ButtonComponent` with `.setButtonText(...)` and `.onClick(...)`. The Process button additionally calls `.setCta()` — the native equivalent of the `mod-cta` class it currently carries. The mobile full-width behavior of the Process button is preserved (the existing mobile class stays on the component's `buttonEl`).
|
||||
- Add `ButtonComponent` to the `obsidian` import.
|
||||
|
||||
### Piece 3 — Modal inputs via `TextComponent` / `DropdownComponent`
|
||||
|
||||
In `YouTubeTranscriptModal`:
|
||||
- The URL `<input type=text>` and the title `<input type=text>` → `TextComponent`. The modal currently keeps element references (`this.urlInputEl`, `this.titleInputEl`) for focus handling, value reads, and `keydown` listeners; these are repointed to the component's `.inputEl`. Placeholders, the `input` event for URL validation, and the Enter-key handling are preserved.
|
||||
- The video-count `<select>` → `DropdownComponent`: add options "1".."50" via `.addOption`, default "1", read via `.getValue()`.
|
||||
- The radio buttons (All videos / Limited number) **stay as raw `<input type=radio>`** — Obsidian has no native radio component.
|
||||
|
||||
## Constraint and acceptance
|
||||
|
||||
- **No visible change** is the acceptance criterion. Layout containers (`form-group`, the modal-controls flex rows, etc.) and their CSS are kept.
|
||||
- Bespoke CSS that becomes genuinely dead because a native component replaces it (e.g. hand-built icon-button styling fully superseded by `ExtraButtonComponent`) is removed. CSS still doing layout or still referenced is kept. The implementation plan identifies dead rules case by case; CSS is only removed when a `grep` confirms no remaining reference.
|
||||
- Verification: `npm run build` clean, `npm run lint` clean (0/0), and a manual before/after comparison against the 1.3.3 release.
|
||||
|
||||
## Risk
|
||||
|
||||
Native components carry Obsidian's default metrics (padding, height, border-radius). Where the bespoke CSS deliberately differed, the native rendering may differ by a few pixels. The layout classes are kept so structure holds. Any visible drift surfaces in the audition against 1.3.3 and is correctable with a small CSS rule on the native component — not a revert.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **#1** — replacing the hand-rolled markdown renderer in `LicenseModal` / `READMEModal` with `MarkdownRenderer`. Visibly changes those modals; deferred.
|
||||
- **#3** — replacing `TemplateFilePickerModal` / `FolderPickerModal` with `FuzzySuggestModal`. Visibly changes the pickers; deferred.
|
||||
- The `ProcessingSpinner` (no native equivalent), modal sizing via CSS, and `addEventListener` vs `registerDomEvent` — all assessed as acceptable, no change.
|
||||
|
||||
## Testing
|
||||
|
||||
No unit-test framework exists; verification is:
|
||||
- `npm run build` clean.
|
||||
- `npm run lint` clean (0 errors, 0 warnings).
|
||||
- `grep` confirms `createElementNS` count dropped to only any intentionally-kept SVGs, and `createEl('button'`/`createEl('input'`/`createEl('select'` in the converted regions are gone.
|
||||
- Manual: in a dev vault, open the create-note modal, the settings tab, and each modal; confirm the icons, buttons, URL/title fields, and video-count dropdown look and behave as in 1.3.3.
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# Transcript fallback: replace broken local methods with the iOS InnerTube player — design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
The free local transcript-extraction fallbacks in `src/youtube-transcript.ts` no longer work. Replace all of them with a single local method built from the YouTube iOS InnerTube player approach documented in `youtube-transcript-fetch-spec.md` (cloned from `lstrzepek/obsidian-yt-transcript`). The two paid-service paths (ScrapeCreators, Supadata) are unchanged. The result is a shorter, more reliable transcript module with one working local fallback.
|
||||
|
||||
## Background
|
||||
|
||||
`YouTubeTranscriptExtractor.fetchTranscript` (`src/youtube-transcript.ts`) currently runs a six-rung ladder:
|
||||
|
||||
1. ScrapeCreators paid API (when `scrapcreatorsApiKey` is set)
|
||||
2. Watch-page captions — scrapes `ytInitialPlayerResponse.captionTracks` out of the watch-page HTML
|
||||
3. ANDROID Player API
|
||||
4. MWEB Player API
|
||||
5. WEB `youtubei/v1/next` → `youtubei/v1/get_transcript`
|
||||
6. Supadata paid API (when `supadataApiKey` is set)
|
||||
|
||||
Rungs 2–5 are the free local methods. They have all stopped working: the spec records that the Android client stopped returning captions in early 2026, and the WEB `get_transcript` route is brittle (`FAILED_PRECONDITION`, session/visitor binding). The user has confirmed all four free local methods fail.
|
||||
|
||||
The spec describes a method that still works: a single `POST` to YouTube's InnerTube `youtubei/v1/player` endpoint using the **iOS** client identity. The iOS player response still exposes `captionTracks[].baseUrl` values that can be downloaded directly without PO tokens, and it also carries `videoDetails` (title, author).
|
||||
|
||||
The ScrapeCreators and Supadata paths return only transcript text, so they call `getVideoMetadata(videoId)` separately for the note's title/author. Today `getVideoMetadata` is backed by the watch-page HTML scrape (`getYouTubeConfig` → `fetchWatchPageHtml`). Since the iOS player response already contains `videoDetails`, `getVideoMetadata` is re-pointed at the iOS player call, which removes the watch-page HTML mechanism entirely.
|
||||
|
||||
## Change
|
||||
|
||||
### New fallback ladder
|
||||
|
||||
`fetchTranscript` runs a three-rung ladder:
|
||||
|
||||
1. **ScrapeCreators paid API** — when `scrapcreatorsApiKey` is set. Unchanged (`fetchViaScrapeCreators`).
|
||||
2. **iOS InnerTube player** — new method `fetchViaIosPlayer`. Always attempted (no key required).
|
||||
3. **Supadata paid API** — when `supadataApiKey` is set. Unchanged (`fetchViaSupadata`).
|
||||
|
||||
The surrounding control flow is unchanged in shape: each method's failure is recorded in the `attempts` array; if every method fails, the existing CORS/network error-message mapping and the "return metadata despite caption failure" partial-result behavior still apply.
|
||||
|
||||
### New: `fetchIosPlayerData(videoId, options)` helper
|
||||
|
||||
A private static method that performs the iOS InnerTube player call and returns the parsed JSON response.
|
||||
|
||||
- Constants (module-level `const`s in the file):
|
||||
- `INNERTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"` — YouTube's public InnerTube key, from the spec.
|
||||
- `INNERTUBE_PLAYER_URL = "https://www.youtube.com/youtubei/v1/player?key=" + INNERTUBE_API_KEY`
|
||||
- `IOS_USER_AGENT = "com.google.ios.youtube/20.10.38 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X)"`
|
||||
- `IOS_CLIENT_VERSION = "20.10.38"`
|
||||
- Request: `POST` via `obsidianFetch` to `INNERTUBE_PLAYER_URL` with
|
||||
- headers `Content-Type: application/json`, `User-Agent: IOS_USER_AGENT`
|
||||
- body `JSON.stringify({ context: { client: { clientName: "IOS", clientVersion: IOS_CLIENT_VERSION, hl: options.lang || "en", gl: options.country || "US" } }, videoId })`
|
||||
- On a non-OK HTTP response, throw `Error("iOS Player API error: HTTP <status>")`.
|
||||
- Parse the response body as JSON.
|
||||
- Playability checks (from the spec) on `data.playabilityStatus`:
|
||||
- `status === "ERROR"` → throw `Error(reason || "Video unavailable")`
|
||||
- `status === "LOGIN_REQUIRED"` → throw `Error("This video requires login to view")`
|
||||
- `status === "UNPLAYABLE"` → throw `Error(reason || "Video is unplayable")`
|
||||
- **Caching:** the parsed response is cached in a single-entry per-video cache (`cachedPlayerData` + `cachedPlayerVideoId` statics), mirroring how `cachedConfig`/`cachedVideoId` work today. A subsequent call for the same `videoId` returns the cached object without a second POST. `fetchTranscript` clears the cache when the `videoId` changes (the existing cache-reset block is repurposed to clear `cachedPlayerData`/`cachedPlayerVideoId`).
|
||||
|
||||
### New: `fetchViaIosPlayer(videoId, options)` method
|
||||
|
||||
A private static method returning `TranscriptResult`.
|
||||
|
||||
- Calls `fetchIosPlayerData(videoId, options)`.
|
||||
- Metadata: read `data.videoDetails` → `{ title, author }` (each used only when it is a string), producing a `TranscriptMetadata`.
|
||||
- Caption tracks: read `data.captions?.playerCaptionsTracklistRenderer?.captionTracks`. If missing or empty, throw `Error("No captions available for this video")`.
|
||||
- Map the raw tracks to the existing `CaptionTrack` interface (`languageCode`, `kind`, `baseUrl`, `vssId`, `isTranslatable`).
|
||||
- Select a track with the **existing** `pickBestTrack(tracks, options.lang || "en")`. If it returns null or the track has no `baseUrl`, throw `Error("No suitable caption track with baseUrl found")`.
|
||||
- Download and parse with the **existing** `fetchCaptionTrack(baseUrl, tlang, IOS_USER_AGENT)` — passing `IOS_USER_AGENT` so the caption fetch keeps a consistent client identity, and `tlang` set to the requested language only when `pickBestTrack` reported `useTlang`.
|
||||
- Return `{ segments, metadata }`.
|
||||
|
||||
### Changed: `getVideoMetadata(videoId)`
|
||||
|
||||
Re-pointed from the watch-page HTML scrape to the iOS player call:
|
||||
|
||||
- Calls `fetchIosPlayerData(videoId, {})` and returns `{ title, author }` from `data.videoDetails` (string fields only).
|
||||
- Keeps its existing `try/catch` that returns `{}` on any failure — metadata extraction remains non-fatal, so a ScrapeCreators or Supadata transcript still succeeds even if the iOS metadata call fails.
|
||||
- The current cached-metadata short-circuit at the top of `getVideoMetadata` (which reads `cachedConfig.metadata`) is removed: `fetchIosPlayerData` is itself cached per `videoId`, so calling it directly already avoids a redundant POST. The method body becomes just the `fetchIosPlayerData` call wrapped in the existing `try/catch`.
|
||||
|
||||
### Deletions
|
||||
|
||||
Remove the following from `src/youtube-transcript.ts`, along with any imports/types that become unused:
|
||||
|
||||
- Methods: `fetchViaWatchPage`, `fetchViaPlayerApiAndroid`, `fetchViaPlayerApiMWEB`, `fetchViaWebScrapeCreators`.
|
||||
- Helpers used only by those methods: `getYouTubeConfig`, `fetchWatchPageHtml`, `extractJsonFromHtml`, `extractMetadataFromNextData`, `findTranscriptEndpoint`, `parseScrapeCreatorsTranscript`.
|
||||
- The `YouTubeConfig` interface.
|
||||
- Statics that become unused: `cachedConfig`, `cachedVideoId`, `FALLBACK_CLIENT_VERSION`, and `cookieStore` (`cookieStore` is already never assigned anywhere in the file, so the `...(cookieStore && { Cookie })` spreads it feeds are already dead — they are removed with it).
|
||||
|
||||
### Kept unchanged
|
||||
|
||||
`fetchViaScrapeCreators`, `fetchViaSupadata`, `pickBestTrack`, `fetchCaptionTrack`, `fetchCaptionTrackWithFormat`, `fetchCaptionTrackRaw`, `parseCaptionTrackResponse`, `parseXmlCaptions`, `makeAbsoluteUrl`, `extractVideoId`, `isValidVideoId`, the `CaptionTrack` / `TranscriptSegment` / `TranscriptOptions` / `TranscriptMetadata` / `TranscriptResult` interfaces, and the outer error handling in `fetchTranscript` (CORS/network mapping, partial-metadata fallback segment).
|
||||
|
||||
Note: `fetchCaptionTrackRaw` currently includes a `...(cookieStore && { Cookie })` header spread. With `cookieStore` removed, that spread is deleted; the rest of `fetchCaptionTrackRaw` (User-Agent, Accept, Origin, Referer, DNT headers) is unchanged.
|
||||
|
||||
## Data flow
|
||||
|
||||
- Transcript request → `fetchTranscript(videoId, options)`.
|
||||
- If `scrapcreatorsApiKey`: try `fetchViaScrapeCreators` → on success it calls `getVideoMetadata` (one iOS player POST) and returns.
|
||||
- Else / on ScrapeCreators failure: try `fetchViaIosPlayer` → one iOS player POST (cached), select track, download caption track, return segments + metadata (no extra metadata call — `videoDetails` is in the same response).
|
||||
- On iOS failure, if `supadataApiKey`: try `fetchViaSupadata` → on success it calls `getVideoMetadata` (one iOS player POST, cache reused if warm) and returns.
|
||||
- All fail → existing aggregate error / partial-metadata behavior.
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
- iOS player non-OK HTTP → throw with the status; recorded in `attempts`.
|
||||
- `playabilityStatus` ERROR / LOGIN_REQUIRED / UNPLAYABLE → throw the spec's messages; recorded in `attempts`.
|
||||
- No `captionTracks` in the iOS response → throw `"No captions available for this video"`.
|
||||
- Caption-track download empty for all formats → the existing `fetchCaptionTrack` already throws `"Caption track returned empty response for all format attempts"`.
|
||||
- `getVideoMetadata` failure stays non-fatal (`try/catch` → `{}`).
|
||||
- Video-ID change mid-session clears the player cache, exactly as the config cache is cleared today.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No change to ScrapeCreators or Supadata request/response handling.
|
||||
- No change to caption parsing (`json3`/`srv3`/XML) or `pickBestTrack`.
|
||||
- No change to settings, UI, or the info icons added in the 1.3.4 batch.
|
||||
- The `youtube-transcript-fetch-spec.md` file itself is not copied into the repo; only the method is implemented.
|
||||
|
||||
## Testing
|
||||
|
||||
This repo has no unit-test framework, and `src/youtube-transcript.ts` hits live YouTube endpoints. Verification:
|
||||
|
||||
- `npm run build` clean (tsc strict + esbuild) — in particular, confirm no dangling references to the deleted methods/helpers/interface and no unused-symbol errors.
|
||||
- `npm run lint` clean (`0 errors, 0 warnings`). No `eslint-disable` comments may be added.
|
||||
- Manual run in a dev vault, on both desktop and mobile:
|
||||
- With no ScrapeCreators/Supadata key configured: extracting a transcript from a normal captioned video succeeds via the iOS player method.
|
||||
- A video with no captions reports "No captions available for this video".
|
||||
- With a ScrapeCreators key configured: ScrapeCreators still runs first and the resulting note still has the correct title/author (metadata now sourced from the iOS player call).
|
||||
Loading…
Reference in a new issue