Compare commits
11 Commits
mraid-chec
...
afbf504957
| Author | SHA1 | Date | |
|---|---|---|---|
| afbf504957 | |||
| 16847a78ca | |||
| ca9cc62b8c | |||
| 8e8d3d6da0 | |||
| 3f86aeaf0c | |||
| 92de00b52e | |||
| 78578cec16 | |||
| 75a506a505 | |||
| 698223234d | |||
| 9e82656eeb | |||
| d384c1be3a |
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
232
aiAssets/GUIDE.md
Normal file
232
aiAssets/GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# AGENTS.md — Playable Ads
|
||||
|
||||
## Stack
|
||||
Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps
|
||||
Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)
|
||||
|
||||
## Porting to a new project
|
||||
1. Update naming fields (3LC, vendor, concept name) in [scripts/build-all.mjs](scripts/build-all.mjs) — drives the output filename
|
||||
2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android)
|
||||
3. Adjust the Depth Map below to match the project's layer needs
|
||||
4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants
|
||||
5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/BootScene.ts)
|
||||
6. Rewrite `game/` modules for the new mechanic(s) — keep the single-responsibility split
|
||||
7. Update this AGENTS.md's Depth Map and any project-specific pitfalls
|
||||
|
||||
## Structure
|
||||
```
|
||||
src/
|
||||
main.ts # Bootstrap, resize, MRAID gating, lifecycle stubs on window
|
||||
constants.ts # Design coords (1080×1920), store URLs
|
||||
networks.ts # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
|
||||
analytics.ts # trackEvent() → SDK
|
||||
iteration.ts # A/B iteration config
|
||||
utils/
|
||||
responsive.ts # sx(), sy(), sd() — coordinate helpers
|
||||
scenes/
|
||||
BootScene.ts # Critical preload + deferred asset manifest
|
||||
GameScene.ts # Orchestrator only — wires game/ modules, handles lifecycle
|
||||
cta.ts # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
|
||||
game/ # Single-responsibility modules (no direct ad-SDK calls)
|
||||
# …one file per distinct mechanic or UI widget
|
||||
Assets/
|
||||
scripts/build-all.mjs # Single build → per-network HTML variants
|
||||
```
|
||||
|
||||
### Responsibility split
|
||||
- **`game/`** modules each own a single concern (one mechanic, one widget, one system).
|
||||
They receive the Phaser scene as a constructor arg and own their own game objects.
|
||||
They never call ad-SDK functions (`triggerCTA`, `notifyGameX`, `trackEvent`).
|
||||
- **`GameScene.ts`** is the wiring layer: creates modules, passes data between them,
|
||||
and calls SDK helpers at the correct lifecycle moments.
|
||||
- **`scenes/cta.ts`** owns end-card presentation and the store-redirect sequence.
|
||||
|
||||
## Rules
|
||||
- 60 FPS target; never below 30 on mid-range Android
|
||||
- `dist/index.html` < 5 MB; WebP images, short audio
|
||||
- No hardcoded px — `sx()`, `sy()`, `sd()` only
|
||||
- No audio autoplay — unmute on first `pointerdown`
|
||||
- `for` loops in game logic; pool objects; no GC
|
||||
- `setDisplaySize(sd())` on images — never `setScale`
|
||||
- Never dim via `body`/page background
|
||||
|
||||
## Phaser Config
|
||||
```ts
|
||||
{ type: Phaser.AUTO, transparent: true,
|
||||
scale: { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
|
||||
render: { antialias: true, pixelArt: false } }
|
||||
```
|
||||
`Scale.NONE` — manual sizing; CSS = viewport px; internal = viewport × DPR.
|
||||
`antialias: true` — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to `false`.
|
||||
|
||||
## Responsive (1080×1920 ref)
|
||||
```ts
|
||||
const s = Math.min(viewW / 1080, viewH / 1920)
|
||||
const offX = (viewW - 1080 * s) / 2
|
||||
const offY = (viewH - 1920 * s) / 2
|
||||
// sx/sy/sd apply offX, offY, s
|
||||
```
|
||||
- `update()` polls `visualViewport` per-frame for rotation
|
||||
- On change: `game.scale.resize(vw*dpr, vh*dpr)` → `relayout()`
|
||||
- `bindResponsiveResize()`: rAF debounce + 100/300/600ms retries
|
||||
|
||||
## CTA — SDK Priority (triggerCTA fallback chain)
|
||||
```
|
||||
1. ExitApi.exit() → GoogleAds (gg)
|
||||
2. FbPlayableAd.onCTAClick() → Facebook (fb)
|
||||
3. Luna.Unity.Playable → Unity (un)
|
||||
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
|
||||
5. window.install() → Mintegral (mtg)
|
||||
6. window.openAppStore() → (runtime fallback)
|
||||
7. window.clickTag → Moloco (mo)
|
||||
8. window.__VUNGLE__ → Vungle (vu) via parent.postMessage
|
||||
9. window.__TIKTOK__ → TikTok (tt) → openAppStore() or window.open fallback
|
||||
10. mraid.open(url) → Applovin (al) / Ironsource (is)
|
||||
11. window.open(storeUrl) → Fallback (all others)
|
||||
```
|
||||
`notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent).
|
||||
|
||||
## Analytics — SDK Priority
|
||||
```
|
||||
ALPlayableAnalytics.trackEvent(e) → Applovin (al)
|
||||
playableSDK.reportEvent(e) → (runtime fallback if SDK present)
|
||||
console.log('[Analytics]', e) → All other networks
|
||||
```
|
||||
Events: `DISPLAYED` · `CTA_CLICKED` · `ENDCARD_SHOWN` · `CHALLENGE_STARTED` · `CHALLENGE_SOLVED`
|
||||
|
||||
## Game Lifecycle (all networks)
|
||||
`main.ts` exposes stubs on `window` so every network's preview tool detects them.
|
||||
Stubs only set if the SDK hasn't already provided its own (`typeof` guard).
|
||||
```
|
||||
gameReady() → stub in main.ts; Mintegral also gets onload body attr from build
|
||||
gameStart() → notifyGameStart() in GameScene.startPlaying()
|
||||
gameEnd() → notifyGameEnd() in GameScene.triggerFail() / triggerWin()
|
||||
gameClose() → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()
|
||||
```
|
||||
|
||||
### Pause / Resume / Mute (bindLifecycle in networks.ts)
|
||||
| Network | Mechanism |
|
||||
|---|---|
|
||||
| Unity | `luna:mute` / `luna:unmute` / `luna:pause` / `luna:resume` events |
|
||||
| Mintegral | `window.message` → `onPause` / `onResume` |
|
||||
| Vungle | `ad-event-pause` / `ad-event-resume` events |
|
||||
| Applovin / Ironsource | MRAID viewable state (gated in initMraid) |
|
||||
| GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility |
|
||||
|
||||
## Build
|
||||
Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`).
|
||||
Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules.
|
||||
|
||||
| Network | Tag | Injected | Zipped | Included |
|
||||
|---|---|---|---|---|
|
||||
| Applovin | al | `mraid.js` | — | ✓ |
|
||||
| Google | gg | `exitapi.js` | ✓ | ✓ |
|
||||
| Ironsource | is | `mraid.js` | — | ✓ |
|
||||
| Mintegral | mtg | `onload="gameReady()"` | ✓ | ✓ |
|
||||
| Facebook | fb | — | — | ✓ |
|
||||
| Unity | un | — | — | ✓ |
|
||||
| Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ |
|
||||
| Moloco | mo | — | — | ✓ |
|
||||
| TikTok | tt | `window.__TIKTOK__=true` | — | — |
|
||||
|
||||
|
||||
Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](scripts/build-all.mjs) writes a `.zip` containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via `zip: true` in the `NETWORKS` array.
|
||||
|
||||
Networks marked **Included** are built by default. Networks not marked are prepared for but not built. Toggle per-network via `included: true` in the `NETWORKS` array.
|
||||
|
||||
Output is grouped by **iteration length** (e.g. `2words/`, `4words/`, `full/`) then by **network** (e.g. `Applovin/`, `Google/`, `Mintegral/`), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:
|
||||
|
||||
`dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>`
|
||||
|
||||
Example layout:
|
||||
```
|
||||
dist/
|
||||
2words/
|
||||
Applovin/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
|
||||
Google/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
|
||||
index.zip
|
||||
Ironsource/
|
||||
ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
|
||||
…one HTML or ZIP per network…
|
||||
4words/
|
||||
full/
|
||||
```
|
||||
|
||||
| Field | Description | Example |
|
||||
|---|---|---|
|
||||
| 3LC | 3-letter project code | `ws` (Wordscapes), `pp` (PeoplePuzzle) |
|
||||
| Creative Type | Format identifier | `mip` |
|
||||
| Vendor | Studio / vendor code | `grhpl` |
|
||||
| Concept Name | Creative concept (append `var1`, `var2`… for variants) | `wbinspiredvar1`, `wbinspiredvar2`, `burglar` |
|
||||
| Concept # | Iteration number, zero-padded | `01`, `02`, `03` |
|
||||
| Gameplay | Gameplay style | `real`, `cartoon` |
|
||||
| UGC | UGC presence | `na`, `ugc`, `nougc` |
|
||||
| Seasonal | Seasonal tie-in | `noseason`, `xmas` |
|
||||
| Language | Locale code | `en` |
|
||||
| Length | Run length | `2words`, `4words`, `7words`, `full` (Wordscapes); `2clk`, `10clk`, `120sec` (other projects) |
|
||||
| Size | Region / size code | `na` |
|
||||
| Network | Network tag (last segment) | `al`, `gg`, `is`, `mtg`, `fb`, `un`, `vu`, `mo`, `tt` |
|
||||
|
||||
Examples (this project):
|
||||
- `ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html` (cta2, default theme)
|
||||
- `ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html` (full, default theme)
|
||||
- `ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html` (cta2, v1 theme)
|
||||
|
||||
Naming fields are defined in [scripts/build-all.mjs](scripts/build-all.mjs) — update them when porting.
|
||||
|
||||
## Asset Loading
|
||||
- **Critical** (BootScene): first-frame assets, target < 200 KB
|
||||
- **Deferred**: audio, overlays, secondary UI — loads during intro
|
||||
- Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading
|
||||
|
||||
## Depth Map (adjust per project)
|
||||
Define depths as named constants in [constants.ts](src/constants.ts) — never use magic numbers in scene code.
|
||||
|
||||
| Layer | Depth |
|
||||
|---|---|
|
||||
| Background | 0 |
|
||||
| Game objects | 1–10 |
|
||||
| Logo | 17 |
|
||||
| Dim overlay | 20 |
|
||||
| Fail/win UI | 21 |
|
||||
| EndCard input | 25 |
|
||||
|
||||
## Ship Checklist
|
||||
- [ ] < 5 MB, all WebP
|
||||
- [ ] FPS ≥ 55 under CPU throttle
|
||||
- [ ] Portrait + landscape pass
|
||||
- [ ] MRAID ready before gameplay
|
||||
- [ ] Audio muted by default
|
||||
- [ ] CTA fires in fallback
|
||||
- [ ] No `console.error`
|
||||
- [ ] No `type="module"` or `crossorigin` on `<script>` in output HTML
|
||||
- [ ] Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools
|
||||
|
||||
## Pitfalls
|
||||
| Issue | Fix |
|
||||
|---|---|
|
||||
| Rotation stuck | `Scale.NONE` + per-frame poll |
|
||||
| Mobile aliasing | Canvas = viewport × DPR |
|
||||
| FPS drop on resize | rAF debounce |
|
||||
| MRAID crash | `typeof mraid !== 'undefined'` guard |
|
||||
| Assets not inlined | `assetsInlineLimit: 100_000_000` |
|
||||
| Bundle > 5 MB | WebP q75, shorter audio |
|
||||
| Audio blocked | Gate on `pointerdown` |
|
||||
| Letterbox | Avoid `Scale.FIT/EXPAND/CENTER_BOTH` |
|
||||
| CORS / module error | `format: 'iife'` + strip `type="module"` and `crossorigin` |
|
||||
| Network preview checklist fail | Expose lifecycle stubs on `window` + call `notifyX()` with `typeof` guards |
|
||||
| `playableSDK.reportEvent is not a function` | Applovin preview injects `playableSDK` without `reportEvent` — guard with `typeof playableSDK.reportEvent === 'function'`, not just `typeof playableSDK !== 'undefined'` |
|
||||
| iPhone black screen (Android works) | **Only one `Phaser.Game` per page, ever.** iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate `new Phaser.Game()` synchronously at module load, it can throw before `await initMraid()` and before the font promise resolves, so the rest of `main.ts` never runs → black screen with no visible error. Render the background as a CSS `background-image` on `#bg-container` (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-`main.ts` `window.addEventListener('error',…)` and `unhandledrejection` handlers that write the error into `document.body.innerHTML` so the failure is visible on-screen. |
|
||||
| iOS hangs on font load | `document.fonts.load()` silently stalls in WKWebView when `@font-face` uses base64 `data:` URLs. Race against a 1.5 s timeout: `Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)` |
|
||||
| iOS hangs on MRAID ready | `mraid.addEventListener('ready', …)` can miss the event if the container fires it before the listener attaches. `initMraid()` must self-resolve after ~2 s so `startGame()` always runs; also check `mraid.getState() !== 'loading'` first. |
|
||||
| External `src=` survives build | `vite-plugin-singlefile` only inlines assets *imported from JS*. A raw `<img src="src/assets/…">` in `index.html` ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM `<img>` elements with no `src`; assign via JS from `assets.ts` at boot (`setEndcardHtmlAssets()`). |
|
||||
| Audio keeps playing when ad hides | Add `visibilitychange` listener in the sound module — mute Phaser's sound system and `suspend()` the `AudioContext` when `document.hidden`, resume on return. Spec requires audio stop on hide/close. |
|
||||
| `mraid.open()` silently no-ops | Guard with `mraid.getState() !== 'loading'` before calling; fall through to `window.open()` if still loading. |
|
||||
| Previewer black screen with `Cannot read properties of null (reading 'appendChild')` in Phaser boot | Two compounding causes: (1) `vite-plugin-singlefile` hoists the bundled script into `<head>`, and the build strips `type="module"`, so the script runs **synchronously before `<body>` is parsed** — `document.body` is null. Wrap main.ts in a `DOMContentLoaded` gate (`if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()`). (2) `parent: 'game'` (string selector) fails when the previewer sandbox strips custom DOM; create `<div id="game">` at runtime inside `boot()` and pass the **element reference** (not the string) to `Phaser.Game({ parent })`. The cascading `ScaleManager.resize → Cannot set properties of undefined (setting 'width')` errors all trace back to `game.canvas` never being created when either of these fail. |
|
||||
| NineSlice won't shrink in landscape | Phaser's `NineSlice` silently clamps its minimum dimensions to `(leftWidth + rightWidth) × (topHeight + bottomHeight)` **in source pixels**. Symptom: one UI chrome element stays huge in landscape while every other `sd()`-driven element rescales correctly — because the requested `sd(H)` is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: **operate the NineSlice in source-pixel space and scale it uniformly with `setScale(sd(1))`**. Keep `setSize(wSrc, hSrc)` in source px so the center stretches horizontally with text growth while corners stay intact; the `sd(1)` scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading `this.preview.width` (which is in scaled px) to decide `wSrc`, divide by `sd(1)` to convert back to source space. Tween callers that reset `setScale(1)` also need updating — the bg's base scale is now `sd(1)`, not 1. See [SelectionLine.ts](src/game/SelectionLine.ts) for the pattern. |
|
||||
| Unity (un) submission: "not allowed to use window.top" | Luna's static scan flags the literal `window.top`, which Phaser 3 ships internally. In `build-all.mjs`, rewrite `window.top` → `window.self` for the `un` HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top. |
|
||||
|
||||
## Ironsource — Runtime Analysis
|
||||
Ironsource requires every `_is.html` build to pass **LevelPlay → Creative Management → Playable Workshop** (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.
|
||||
19
aiAssets/LAYOUT.md
Normal file
19
aiAssets/LAYOUT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Unified Layout
|
||||
|
||||
[Select Folder] [Select Files] [Clear]
|
||||
|
||||
(PLEC UPLOAD)
|
||||
| FILENAME |ITERATION NAME| |
|
||||
|-----------------|--------------|---|
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|
||||
(AppLovin Playable Preview)(Base64 Scanner)(Send To Mobile)(MRAID Checker)
|
||||
| FILENAME | |
|
||||
|-----------------|-------|
|
||||
|pato-to-file.html| X |
|
||||
|pato-to-file.html| X |
|
||||
|pato-to-file.html| X |
|
||||
|
||||
[Scan]/[Upload]/[Start]
|
||||
7
aiAssets/Playworks_UnityNetwork.html
Normal file
7
aiAssets/Playworks_UnityNetwork.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_al.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_fb.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_gg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_is.html
Normal file
File diff suppressed because one or more lines are too long
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_mo.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip
Normal file
Binary file not shown.
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
8
aiAssets/testOutput/Playworks_UnityNetwork_un.html
Normal file
File diff suppressed because one or more lines are too long
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
BIN
aiAssets/testOutput/Playworks_UnityNetwork_vu.zip
Normal file
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
BIN
dist/hpl-toolbox-0.1.5.vsix
vendored
Binary file not shown.
Binary file not shown.
BIN
dist/hpl-toolbox-0.1.6.vsix
vendored
Normal file
BIN
dist/hpl-toolbox-0.1.6.vsix
vendored
Normal file
Binary file not shown.
23
package.json
23
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker, Daily Update. Local HTML Host.",
|
||||
"version": "0.1.5",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.6",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
@@ -12,7 +12,9 @@
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": ["Other"],
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"icon": "media/hpl.png",
|
||||
"main": "./out/extension.js",
|
||||
"activationEvents": [],
|
||||
@@ -24,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openApplovinUpload",
|
||||
"title": "HPL Toolbox: Open AppLovin Demo Upload"
|
||||
"title": "HPL Toolbox: Open AppLovin Playable Preview"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openBase64Scanner",
|
||||
@@ -34,13 +36,13 @@
|
||||
"command": "hplToolbox.openMraidChecker",
|
||||
"title": "HPL Toolbox: Open MRAID Checker"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openDailyUpdate",
|
||||
"title": "HPL Toolbox: Open Daily Update"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openSendToMobile",
|
||||
"title": "HPL Toolbox: Open Send To Mobile"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openPlayworksConverter",
|
||||
"title": "HPL Toolbox: Open Playworks Converter"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
@@ -57,7 +59,7 @@
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "hplToolbox.launcher",
|
||||
"name": "Tools"
|
||||
"name": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -121,7 +123,8 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "tsc -p ./",
|
||||
"sync-readme": "node scripts/sync-readme.js",
|
||||
"compile": "npm run sync-readme && tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"vscode:prepublish": "npm run compile"
|
||||
},
|
||||
|
||||
20
scripts/sync-readme.js
Normal file
20
scripts/sync-readme.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const changelogPath = path.join(root, 'CHANGELOG.md');
|
||||
const readmePath = path.join(root, 'README.md');
|
||||
|
||||
let changelog = '# CHANGELOG\n\nNo changelog entries yet.';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
changelog = fs.readFileSync(changelogPath, 'utf8').trim();
|
||||
}
|
||||
|
||||
const readme = `# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
${changelog}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(readmePath, readme.replace(/\r?\n/g, '\n'), 'utf8');
|
||||
122
src/changelogView.ts
Normal file
122
src/changelogView.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export function openChangelog(context: vscode.ExtensionContext) {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
'hplToolbox.changelog',
|
||||
'HPL Toolbox Changelog',
|
||||
vscode.ViewColumn.One,
|
||||
{ enableScripts: false }
|
||||
);
|
||||
const changelogPath = path.join(context.extensionPath, 'CHANGELOG.md');
|
||||
let markdown = '# Changelog\n\nCHANGELOG.md was not found.';
|
||||
try {
|
||||
markdown = fs.readFileSync(changelogPath, 'utf8');
|
||||
} catch {
|
||||
// Keep the fallback text readable in packaged or development installs.
|
||||
}
|
||||
panel.webview.html = getHtml(renderMarkdown(markdown));
|
||||
}
|
||||
|
||||
function getHtml(content: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
main {
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h2 {
|
||||
margin: 18px 0 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
ul {
|
||||
margin: 6px 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
li {
|
||||
margin: 4px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>${content}</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = Math.min(heading[1].length, 2);
|
||||
html.push(`<h${level}>${escapeHtml(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const listItem = line.match(/^-\s+(.+)$/);
|
||||
if (listItem) {
|
||||
if (!inList) {
|
||||
html.push('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${escapeHtml(listItem[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${escapeHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { openPlecUpload } from './tools/plecUpload';
|
||||
import { openApplovinUpload } from './tools/applovinUpload';
|
||||
import { openBase64Scanner } from './tools/base64Scanner';
|
||||
import { openMraidChecker } from './tools/mraidChecker';
|
||||
import { openDailyUpdate } from './tools/dailyUpdate';
|
||||
import { openSendToMobile } from './tools/sendToMobile';
|
||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||
import { openChangelog } from './changelogView';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
@@ -13,8 +14,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openApplovinUpload',
|
||||
title: 'AppLovin Demo Upload',
|
||||
title: 'AppLovin Playable Preview',
|
||||
description: 'Upload to p.applov.in (QR preview)',
|
||||
},
|
||||
{
|
||||
@@ -31,20 +31,21 @@ const TOOLS: ToolDefinition[] = [
|
||||
description: 'Check MRAID requirements and best practices',
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openDailyUpdate',
|
||||
title: 'Daily Update',
|
||||
description: 'Compose & copy a daily status',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openSendToMobile',
|
||||
title: 'Send To Mobile',
|
||||
description: 'Share HTML to a phone via LAN + QR',
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openPlayworksConverter',
|
||||
title: 'Playworks Converter',
|
||||
description: 'Convert Playworks HTML to per-network variants',
|
||||
beta: true,
|
||||
},
|
||||
];
|
||||
|
||||
export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
constructor(private readonly context: vscode.ExtensionContext) { }
|
||||
|
||||
resolveWebviewView(view: vscode.WebviewView) {
|
||||
view.webview.options = { enableScripts: true };
|
||||
@@ -54,6 +55,8 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
view.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||
vscode.commands.executeCommand(msg.command);
|
||||
} else if (msg?.type === 'openChangelog') {
|
||||
vscode.commands.executeCommand('hplToolbox.openChangelog');
|
||||
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
|
||||
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
|
||||
view.webview.html = getHtml(version, msg.enabled);
|
||||
@@ -63,38 +66,74 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
const visibleTools = TOOLS.filter(tool => betaToolsEnabled || !tool.beta);
|
||||
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
||||
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
|
||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
min-height: calc(100vh - 24px);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 10px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
|
||||
color: var(--vscode-foreground);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
||||
.tools { flex: 1; }
|
||||
.tools {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.tool-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px; cursor: pointer; font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-button-secondaryBackground));
|
||||
color: var(--vscode-foreground);
|
||||
border: 1px solid var(--vscode-panel-border, transparent);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.tool-btn:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||
}
|
||||
.tool-btn:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.tool-btn:active {
|
||||
background: var(--vscode-list-activeSelectionBackground, var(--vscode-button-background));
|
||||
color: var(--vscode-list-activeSelectionForeground, var(--vscode-button-foreground));
|
||||
}
|
||||
.tool-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tool-desc {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tool-title { display: flex; align-items: center; gap: 6px; }
|
||||
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||
.beta-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
@@ -102,37 +141,54 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.beta-toggle {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
.beta-toggle button {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
.footer-action {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.beta-toggle button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
#betaToggle {
|
||||
justify-self: start;
|
||||
}
|
||||
.footer-action:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer-version {
|
||||
opacity: 0.75;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.changelog-link {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>HPL Toolbox ${version}</h3>
|
||||
<div class="tools">
|
||||
${toolButtons}
|
||||
</div>
|
||||
<div class="beta-toggle">
|
||||
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||
</button>
|
||||
<div class="footer">
|
||||
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
||||
<span class="footer-version">${version} - JJGC 00784</span>
|
||||
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
@@ -145,11 +201,18 @@ ${toolButtons}
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||
});
|
||||
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'openChangelog' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function sortToolsForDisplay(tools: ToolDefinition[]): ToolDefinition[] {
|
||||
return [...tools].sort((a, b) => Number(Boolean(a.beta)) - Number(Boolean(b.beta)));
|
||||
}
|
||||
|
||||
function renderToolButton(tool: ToolDefinition): string {
|
||||
return ` <button class="tool-btn" data-cmd="${escapeHtml(tool.command)}">
|
||||
<span class="tool-title">${escapeHtml(tool.title)}${tool.beta ? ' <span class="beta-badge">Beta</span>' : ''}</span>
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload');
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Playable Preview');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'filesSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: true,
|
||||
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
return vscode.Uri.file(root);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function qrBaseName(name: string): string {
|
||||
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||
}
|
||||
@@ -243,54 +285,87 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
${getToolWebviewStyles()}
|
||||
.selected-files { margin-top: 10px; }
|
||||
.qr-cell { text-align: center; }
|
||||
.qr-cell img {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
max-width: 100%;
|
||||
padding: 6px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
.result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qr-cell img { cursor: zoom-in; }
|
||||
.qr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: 10;
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.qr-modal.open { display: flex; }
|
||||
.qr-modal img {
|
||||
width: min(70vw, 520px);
|
||||
height: min(70vw, 520px);
|
||||
padding: 18px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; }
|
||||
.qr-results:not(.has-errors) .error-col { display: none; }
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
</header>
|
||||
|
||||
<div class="file-cell" style="margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span class="file-name" id="fileName">(no files)</span>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-files"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div class="actions">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
@@ -298,9 +373,13 @@ function getHtml(): string {
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
let resultItems = [];
|
||||
|
||||
function basename(p) {
|
||||
@@ -308,15 +387,51 @@ function getHtml(): string {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
visiblePaths.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return (
|
||||
'<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||
);
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedPaths.splice(Number(btn.dataset.index), 1);
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (selectedPaths.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', () => {
|
||||
@@ -331,24 +446,30 @@ function getHtml(): string {
|
||||
img.src = base + '&_=' + Date.now();
|
||||
});
|
||||
});
|
||||
qrModal.addEventListener('click', () => {
|
||||
qrModal.classList.remove('open');
|
||||
});
|
||||
|
||||
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||
});
|
||||
|
||||
@@ -356,20 +477,34 @@ function getHtml(): string {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table qr-results">' +
|
||||
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const added = m.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
} else if (m.type === 'start') {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel';
|
||||
} else if (m.type === 'error') {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'err';
|
||||
@@ -378,20 +513,32 @@ function getHtml(): string {
|
||||
statusEl.appendChild(div);
|
||||
uploadBtn.disabled = false;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td class="mono wrap">' + escapeHtml(m.name) + '</td>' +
|
||||
'<td class="muted">-</td>' +
|
||||
'<td class="muted">-</td>' +
|
||||
'<td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||
qr.querySelector('img').addEventListener('click', (event) => {
|
||||
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||
qrModal.classList.add('open');
|
||||
});
|
||||
const actionCell = document.createElement('td');
|
||||
const error = document.createElement('td');
|
||||
error.className = 'error-col';
|
||||
error.innerHTML = '<span class="muted">-</span>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const openPreview = document.createElement('button');
|
||||
@@ -404,13 +551,12 @@ function getHtml(): string {
|
||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||
actions.appendChild(openPreview);
|
||||
actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Asset Scanner');
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
const files = await collectHtmlFiles(distPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -144,46 +146,125 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 14px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; }
|
||||
.summary { margin-top: 12px; opacity: 0.8; }
|
||||
${getToolWebviewStyles()}
|
||||
.asset-list { margin: 0; padding-left: 16px; }
|
||||
.asset-list li + li { margin-top: 3px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const items = pickedFiles.length
|
||||
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||
if (!items.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
selectionEl.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
visibleItems.map(item =>
|
||||
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||
).join('') +
|
||||
'</tbody></table>' +
|
||||
'</div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.type === 'folder') {
|
||||
pickedFolder = '';
|
||||
folderEl.value = '';
|
||||
} else {
|
||||
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||
}
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -191,9 +272,20 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
folderEl.value = '';
|
||||
pickedFiles = [];
|
||||
pickedFolder = '';
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -207,35 +299,63 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
pickedFolder = '';
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const total = msg.results.length;
|
||||
const flagged = msg.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of msg.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = r.file;
|
||||
row.appendChild(mark);
|
||||
row.appendChild(name);
|
||||
if (!r.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + r.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const row = document.createElement('tr');
|
||||
const status = document.createElement('td');
|
||||
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
|
||||
const name = document.createElement('td');
|
||||
name.className = 'mono wrap';
|
||||
name.textContent = r.file;
|
||||
const count = document.createElement('td');
|
||||
count.textContent = String(r.assets.length);
|
||||
const assets = document.createElement('td');
|
||||
assets.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'asset-list mono';
|
||||
r.assets.forEach(asset => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = asset;
|
||||
list.appendChild(item);
|
||||
});
|
||||
assets.appendChild(list);
|
||||
} else {
|
||||
assets.innerHTML = '<span class="muted">None</span>';
|
||||
}
|
||||
row.append(status, name, count, assets);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { singletonPanel } from './shared';
|
||||
|
||||
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openDailyUpdate(context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
|
||||
if (!isNew) return;
|
||||
|
||||
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
|
||||
panel.webview.html = getHtml(lastProject);
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg.type === 'submit') {
|
||||
const text = formatMessage(msg.payload);
|
||||
await vscode.env.clipboard.writeText(text);
|
||||
await context.globalState.update(PROJECT_KEY, msg.payload.project);
|
||||
vscode.window.showInformationMessage('Daily update copied to clipboard.');
|
||||
panel.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
|
||||
const bullets = p.remarks
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.map((l) => `- ${l}`)
|
||||
.join('\n');
|
||||
|
||||
const lines = [
|
||||
`Date: ${formatDate(p.date)}`,
|
||||
`Status: ${p.status}`,
|
||||
`Project: ${p.project}`,
|
||||
`Remarks:`,
|
||||
bullets,
|
||||
];
|
||||
|
||||
if (p.status === 'For OT') {
|
||||
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!m) return iso;
|
||||
return `${m[2]}/${m[3]}/${m[1]}`;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function getHtml(lastProject: string): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
|
||||
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
|
||||
input, select, textarea {
|
||||
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
|
||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
|
||||
}
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
button {
|
||||
margin-top: 16px; padding: 8px 16px; cursor: pointer;
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; font-size: 13px;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
|
||||
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
|
||||
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" value="${today}" />
|
||||
|
||||
<label for="status">Status</label>
|
||||
<select id="status">
|
||||
<option>Started</option>
|
||||
<option>Progress</option>
|
||||
<option>Finished</option>
|
||||
<option>Client Feedback</option>
|
||||
<option>For OT</option>
|
||||
</select>
|
||||
|
||||
<label for="project">Project</label>
|
||||
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
|
||||
|
||||
<label for="remarks">Remarks</label>
|
||||
<textarea id="remarks" placeholder="What did you do?"></textarea>
|
||||
|
||||
<button id="submit">Submit</button>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.getElementById('submit').addEventListener('click', () => {
|
||||
vscode.postMessage({
|
||||
type: 'submit',
|
||||
payload: {
|
||||
date: document.getElementById('date').value,
|
||||
status: document.getElementById('status').value,
|
||||
project: document.getElementById('project').value.trim(),
|
||||
remarks: document.getElementById('remarks').value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -50,7 +50,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
@@ -61,7 +62,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -80,15 +82,21 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
let targets: string[] = [];
|
||||
let baseDir = '';
|
||||
let skipped = 0;
|
||||
let skippedReason = '';
|
||||
let noTargetsError = 'No HTML files were found.';
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
targets = existing;
|
||||
skipped = files.length - existing.length;
|
||||
skippedReason = 'missing file(s).';
|
||||
noTargetsError = 'No selected HTML files were found.';
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
targets = allHtmlFiles.filter(isInSupportedMraidFolderPath);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.';
|
||||
noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.';
|
||||
baseDir = folder;
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||
@@ -100,7 +108,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
type: 'results',
|
||||
results: [],
|
||||
skipped,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
skippedReason,
|
||||
error: noTargetsError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -116,7 +125,7 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
// Skip unreadable files, matching the scanner pattern used elsewhere.
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'results', results, skipped });
|
||||
panel.webview.postMessage({ type: 'results', results, skipped, skippedReason });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -193,9 +202,24 @@ function collectMraidCallLines(html: string): Map<string, number> {
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
function isInSupportedMraidFolderPath(filePath: string): boolean {
|
||||
const folderParts = path.dirname(filePath)
|
||||
.split(/[\\/]+/)
|
||||
.map(part => part.toLowerCase());
|
||||
return folderParts.some(part => [
|
||||
'al',
|
||||
'applovin',
|
||||
'app-lovin',
|
||||
'is',
|
||||
'ironsource',
|
||||
'iron-source',
|
||||
'iron_source',
|
||||
'un',
|
||||
'unity',
|
||||
'unityads',
|
||||
'unity-ads',
|
||||
'unity_ads',
|
||||
].includes(part));
|
||||
}
|
||||
|
||||
function hasMraidReference(ctx: ScanContext): boolean {
|
||||
@@ -362,53 +386,76 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
|
||||
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
|
||||
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
|
||||
${getToolWebviewStyles()}
|
||||
.issues { margin: 0; padding: 0; }
|
||||
.issue { list-style: none; margin: 0 0 8px; }
|
||||
.issue:last-child { margin-bottom: 0; }
|
||||
.severity-badge {
|
||||
display: inline-flex;
|
||||
min-width: 82px;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tool-border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
|
||||
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
@@ -421,7 +468,57 @@ function getHtml(): string {
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const items = pickedFiles.length
|
||||
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||
if (!items.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
selectionEl.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
visibleItems.map(item =>
|
||||
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||
).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.type === 'folder') {
|
||||
pickedFolder = '';
|
||||
folderEl.value = '';
|
||||
} else {
|
||||
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||
}
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -429,9 +526,20 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
folderEl.value = '';
|
||||
pickedFolder = '';
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -445,32 +553,48 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
pickedFolder = '';
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const total = msg.results.length;
|
||||
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(msg.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
const skippedReason = msg.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
|
||||
'<span class="file-name">' + escapeText(r.file) + '</span>' +
|
||||
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
|
||||
'</div>';
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = r.file;
|
||||
const issues = document.createElement('td');
|
||||
issues.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
@@ -479,14 +603,17 @@ function getHtml(): string {
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML =
|
||||
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
|
||||
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
issues.appendChild(list);
|
||||
} else {
|
||||
issues.innerHTML = '<span class="badge ok">OK</span>';
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
row.append(file, issues);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
537
src/tools/playworksConverter.ts
Normal file
537
src/tools/playworksConverter.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
interface ConvertOptions {
|
||||
sourcePath: string;
|
||||
outputDir: string;
|
||||
baseName?: string;
|
||||
networks: string[];
|
||||
}
|
||||
|
||||
interface NetworkResult {
|
||||
network: string;
|
||||
file: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'pickSource': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: false,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select Playworks HTML',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
const srcPath = picked[0].fsPath;
|
||||
const dir = path.dirname(srcPath);
|
||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
|
||||
break;
|
||||
}
|
||||
case 'pickOutput': {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Output Folder',
|
||||
});
|
||||
if (!picked?.[0]) return;
|
||||
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
|
||||
break;
|
||||
}
|
||||
case 'convert': {
|
||||
const opts: ConvertOptions = msg.opts;
|
||||
if (!fs.existsSync(opts.sourcePath)) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(opts.outputDir)) {
|
||||
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(opts.sourcePath, 'utf8');
|
||||
} catch (e: any) {
|
||||
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const results: NetworkResult[] = [];
|
||||
for (const network of opts.networks) {
|
||||
try {
|
||||
const filePath = await convertNetwork(html, opts, network);
|
||||
results.push({ network, file: filePath, ok: true });
|
||||
} catch (e: any) {
|
||||
results.push({ network, file: '', ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'convertDone', results });
|
||||
break;
|
||||
}
|
||||
case 'openOutput': {
|
||||
vscode.env.openExternal(vscode.Uri.file(msg.dir));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||
const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
|
||||
al: 'Applovin',
|
||||
is: 'Ironsource',
|
||||
un: 'Unity',
|
||||
fb: 'Facebook',
|
||||
gg: 'GoogleAds',
|
||||
mo: 'Moloco',
|
||||
vu: 'Vungle',
|
||||
mtg: 'Mintegral',
|
||||
tt: 'TikTok',
|
||||
};
|
||||
|
||||
function sourceBaseName(sourcePath: string): string {
|
||||
return path.basename(sourcePath, path.extname(sourcePath))
|
||||
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||
}
|
||||
|
||||
function outputBaseName(opts: ConvertOptions): string {
|
||||
const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath);
|
||||
const withoutExtension = rawBaseName.replace(/\.html?$/i, '');
|
||||
const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
return safeBaseName || sourceBaseName(opts.sourcePath);
|
||||
}
|
||||
|
||||
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||
const transformed = transformHtml(html, network);
|
||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||
const baseName = outputBaseName(opts);
|
||||
const htmlFileName = `${baseName}_${network}.html`;
|
||||
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (needsZip) {
|
||||
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
|
||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||
try {
|
||||
await createZip(tmpPath, 'index.html', zipPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(outputDir, htmlFileName);
|
||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function transformHtml(html: string, network: string): string {
|
||||
let result = html;
|
||||
|
||||
// Inject into </head>: lifecycle stubs only (must register luna:build listener
|
||||
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
|
||||
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
|
||||
let headInject = buildLifecycleScript();
|
||||
if (network === 'al' || network === 'is') {
|
||||
headInject += '<script src="mraid.js"></script>';
|
||||
} else if (network === 'gg') {
|
||||
headInject += '<script src="exitapi.js"></script>';
|
||||
}
|
||||
result = result.replace('</head>', headInject + '\n</head>');
|
||||
|
||||
// Body-level flags
|
||||
if (network === 'vu') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__VUNGLE__=true;</script>');
|
||||
} else if (network === 'mtg') {
|
||||
result = result.replace('<body>', '<body onload="gameReady()">');
|
||||
} else if (network === 'tt') {
|
||||
result = result.replace('<body>', '<body>\n<script>window.__TIKTOK__=true;</script>');
|
||||
}
|
||||
|
||||
// Replace the CTA script
|
||||
result = replaceCTAScript(result, network);
|
||||
|
||||
// Unity: rewrite window.top → window.self (Luna static scan requirement)
|
||||
if (network === 'un') {
|
||||
result = result.split('window.top').join('window.self');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceCTAScript(html: string, network: string): string {
|
||||
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
|
||||
const markerIdx = html.lastIndexOf(marker);
|
||||
if (markerIdx === -1) return html;
|
||||
|
||||
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
|
||||
if (scriptOpenIdx === -1) return html;
|
||||
|
||||
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
|
||||
if (scriptCloseIdx === -1) return html;
|
||||
|
||||
const before = html.slice(0, scriptOpenIdx);
|
||||
const after = html.slice(scriptCloseIdx + '</script>'.length);
|
||||
|
||||
// Console restore runs after the bundle (which overrides console), before CTA
|
||||
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
|
||||
}
|
||||
|
||||
// Restores native console methods overridden by the Playworks bundle,
|
||||
// using a temporary iframe to get a fresh un-patched console reference.
|
||||
function buildConsoleRestoreScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'try{',
|
||||
'var f=document.createElement("iframe");',
|
||||
'f.style.display="none";',
|
||||
'document.documentElement.appendChild(f);',
|
||||
'var nc=f.contentWindow.console;',
|
||||
'document.documentElement.removeChild(f);',
|
||||
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
|
||||
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
|
||||
'}catch(e){}',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
|
||||
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
|
||||
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
|
||||
// One-time guards prevent double-calls:
|
||||
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
|
||||
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
|
||||
function buildLifecycleScript(): string {
|
||||
return [
|
||||
'<script>(function(){',
|
||||
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
|
||||
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
|
||||
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
|
||||
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
|
||||
'var _grOnce=false,_gsOnce=false;',
|
||||
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
|
||||
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
|
||||
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
|
||||
'})();</script>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function buildCTAScript(network: string): string {
|
||||
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
|
||||
// gameClose must fire before every redirect
|
||||
const closeCall = `try{window.gameClose();}catch(e){}`;
|
||||
|
||||
let ctaLogic: string;
|
||||
switch (network) {
|
||||
case 'al':
|
||||
case 'is':
|
||||
// MRAID — mraid.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'un':
|
||||
// Unity Ads — MRAID-based; window.top already rewritten separately
|
||||
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`;
|
||||
break;
|
||||
case 'fb':
|
||||
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'gg':
|
||||
// Google — exitapi.js injected in <head>
|
||||
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'mo':
|
||||
ctaLogic = `${closeCall}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'vu':
|
||||
// Vungle — window.__VUNGLE__ flag set in body
|
||||
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
|
||||
break;
|
||||
case 'mtg':
|
||||
// Mintegral — onload="gameReady()" set on body
|
||||
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
case 'tt':
|
||||
// TikTok — window.__TIKTOK__ flag set in body
|
||||
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
|
||||
break;
|
||||
default:
|
||||
ctaLogic = `${closeCall}window.open(o,"_blank");`;
|
||||
}
|
||||
|
||||
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
|
||||
}
|
||||
|
||||
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Remove existing zip if present (Compress-Archive would fail otherwise)
|
||||
if (fs.existsSync(destZipPath)) {
|
||||
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const psScript = [
|
||||
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
|
||||
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
|
||||
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
|
||||
'$zip.Dispose()',
|
||||
].join(' ');
|
||||
|
||||
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Webview UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getHtml(): string {
|
||||
const networks = [
|
||||
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
|
||||
{ tag: 'un', label: 'Unity', note: 'HTML (window.top → self)' },
|
||||
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
|
||||
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
|
||||
{ tag: 'mo', label: 'Moloco', note: 'HTML' },
|
||||
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
|
||||
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
|
||||
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
|
||||
];
|
||||
|
||||
const checkboxRows = networks.map(n => `
|
||||
<tr>
|
||||
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
|
||||
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
|
||||
<td><span class="net-note">${n.note}</span></td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
${getToolWebviewStyles()}
|
||||
.net-label { min-width: 100px; font-weight: 500; }
|
||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||
.net-note { font-size: 11px; opacity: 0.6; }
|
||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Playworks Converter</h2>
|
||||
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div class="field-row">
|
||||
<input id="src" type="hidden" />
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
<span id="srcName" class="file-name">(no file)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut" class="secondary">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to Generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll" class="secondary">Select All</button>
|
||||
<button id="selectNone" class="secondary">Select None</button>
|
||||
</div>
|
||||
<div class="results-panel" style="margin-top:0;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
|
||||
<tbody>${checkboxRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
<div class="action-row">
|
||||
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let outputDir = '';
|
||||
|
||||
const srcEl = document.getElementById('src');
|
||||
const srcNameEl = document.getElementById('srcName');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
document.getElementById('convert').addEventListener('click', () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')]
|
||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
convertBtn.disabled = true;
|
||||
vscode.postMessage({
|
||||
type: 'convert',
|
||||
opts: {
|
||||
sourcePath: srcEl.value,
|
||||
outputDir: outEl.value,
|
||||
baseName: baseNameEl.value,
|
||||
networks,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'sourcePicked') {
|
||||
srcEl.value = msg.path;
|
||||
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
|
||||
baseNameEl.value = msg.baseName || '';
|
||||
if (!outEl.value && msg.dir) {
|
||||
outEl.value = msg.dir;
|
||||
outputDir = msg.dir;
|
||||
}
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'outputPicked') {
|
||||
outEl.value = msg.path;
|
||||
outputDir = msg.path;
|
||||
updateConvertBtn();
|
||||
} else if (msg.type === 'convertDone') {
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
if (!results.length) {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of results) {
|
||||
const row = document.createElement('tr');
|
||||
const net = document.createElement('td');
|
||||
net.textContent = r.network;
|
||||
const status = document.createElement('td');
|
||||
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
|
||||
const outputType = document.createElement('td');
|
||||
const detail = document.createElement('td');
|
||||
detail.className = 'mono wrap';
|
||||
const error = document.createElement('td');
|
||||
error.className = 'err wrap';
|
||||
if (r.ok) {
|
||||
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
|
||||
detail.textContent = outputDisplayName(r.file);
|
||||
} else {
|
||||
outputType.textContent = '-';
|
||||
detail.innerHTML = '<span class="muted">-</span>';
|
||||
error.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
|
||||
row.append(net, status, outputType, detail, error);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
if (ok > 0) {
|
||||
outputDir = outEl.value;
|
||||
openOutBtn.style.display = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -15,6 +15,24 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'filesSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: true,
|
||||
@@ -159,78 +177,100 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
return vscode.Uri.file(root);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
th, td { padding: 6px 8px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
th { font-weight: 600; font-size: 12px; text-transform: uppercase; opacity: 0.7; }
|
||||
input[type=text] {
|
||||
width: 100%; padding: 4px 6px;
|
||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
${getToolWebviewStyles()}
|
||||
#rows input[type=text] { width: 100%; }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
margin-top: 12px; padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.pick { min-width: 82px; }
|
||||
.remove-btn {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.result a { color: var(--vscode-textLink-foreground); }
|
||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
.remove-btn { background: transparent; color: var(--vscode-foreground); padding: 4px 8px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>PLEC Upload</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">PLEC Upload</h2>
|
||||
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||
</header>
|
||||
|
||||
<table id="rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:45%">HTML File</th>
|
||||
<th style="width:45%">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<button id="openResult" class="secondary" disabled>Open</button>
|
||||
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const rowsPanel = document.getElementById('rowsPanel');
|
||||
const emptySelection = document.getElementById('emptySelection');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\\.html?$/i, '');
|
||||
@@ -241,87 +281,143 @@ function getHtml(): string {
|
||||
return base;
|
||||
}
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const openResultBtn = document.getElementById('openResult');
|
||||
const copyResultBtn = document.getElementById('copyResult');
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = id;
|
||||
tr.innerHTML = \`
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<button class="pick secondary">Choose...</button>
|
||||
<span class="file-name" data-name>(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" data-path />
|
||||
<div class="row-error" data-error></div>
|
||||
</td>
|
||||
<td><input type="text" data-iter placeholder="iteration name" /></td>
|
||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
||||
\`;
|
||||
tr.querySelector('.pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFile', rowId: id });
|
||||
});
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
if (!tbody.children.length) addRow();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
document.getElementById('addRow').addEventListener('click', addRow);
|
||||
function addFiles(files) {
|
||||
const existing = new Set(rows.map(r => r.path));
|
||||
files.forEach(f => {
|
||||
if (existing.has(f.path)) return;
|
||||
rows.push({
|
||||
id: 'r' + (nextId++),
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
iteration: parseIterationName(f.name),
|
||||
error: ''
|
||||
});
|
||||
existing.add(f.path);
|
||||
});
|
||||
normalizeDuplicateIterations(files.length);
|
||||
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
renderRows();
|
||||
}
|
||||
|
||||
function normalizeDuplicateIterations(addedCount) {
|
||||
if (addedCount <= 1) return;
|
||||
const added = rows.slice(Math.max(0, rows.length - addedCount));
|
||||
if (!added.length) return;
|
||||
const first = added[0].iteration;
|
||||
if (first && added.every(r => r.iteration === first)) {
|
||||
added.forEach((r, i) => {
|
||||
r.iteration = String(i + 1).padStart(2, '0') + '_' + first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
page = Math.min(page, maxPage);
|
||||
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||
const i = start + offset;
|
||||
return \`
|
||||
<tr data-id="\${row.id}">
|
||||
<td>
|
||||
<span class="mono wrap">\${escapeHtml(row.name)}</span>
|
||||
<div class="row-error" data-error>\${escapeHtml(row.error || '')}</div>
|
||||
</td>
|
||||
<td><input type="text" data-iter data-index="\${i}" value="\${escapeHtml(row.iteration)}" /></td>
|
||||
<td><button class="remove-btn danger" data-remove="\${i}" title="Remove">×</button></td>
|
||||
</tr>
|
||||
\`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
rows[Number(input.dataset.index)].iteration = input.value;
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
rows.splice(Number(btn.dataset.remove), 1);
|
||||
renderRows();
|
||||
});
|
||||
});
|
||||
if (rows.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
document.querySelector('#rows').parentElement.after(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||
}
|
||||
}
|
||||
|
||||
function setPreview(url) {
|
||||
previewUrl = url || '';
|
||||
openResultBtn.disabled = !previewUrl;
|
||||
copyResultBtn.disabled = !previewUrl;
|
||||
}
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFile' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
rows = [];
|
||||
page = 0;
|
||||
setPreview('');
|
||||
renderRows();
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
|
||||
document.getElementById('upload').addEventListener('click', () => {
|
||||
clearErrors();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
setPreview('');
|
||||
if (rows.length === 0) {
|
||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
||||
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||
return;
|
||||
}
|
||||
vscode.postMessage({ type: 'upload', rows });
|
||||
});
|
||||
openResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
||||
});
|
||||
copyResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) vscode.postMessage({ type: 'copy', text: previewUrl });
|
||||
});
|
||||
|
||||
function clearErrors() {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
const affected = [];
|
||||
m.files.forEach(function(f, i) {
|
||||
let tr;
|
||||
if (i === 0) {
|
||||
tr = firstTr;
|
||||
} else {
|
||||
addRow();
|
||||
tr = tbody.lastElementChild;
|
||||
}
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = f.path;
|
||||
tr.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(tr);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
addFiles(m.files || []);
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel';
|
||||
} else if (m.type === 'error') {
|
||||
statusEl.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
@@ -329,47 +425,17 @@ function getHtml(): string {
|
||||
div.textContent = m.message;
|
||||
statusEl.appendChild(div);
|
||||
} else if (m.type === 'rowError') {
|
||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = m.message;
|
||||
const row = rows.find(r => r.id === m.rowId);
|
||||
if (row) row.error = m.message;
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
} else if (m.type === 'result') {
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (m.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
|
||||
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(m.preview);
|
||||
}
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import { handleClipboardAndOpen } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -12,7 +12,11 @@ interface ActiveShare {
|
||||
server: http.Server;
|
||||
port: number;
|
||||
token: string;
|
||||
fileBuf: Buffer;
|
||||
files: SharedFile[];
|
||||
}
|
||||
|
||||
interface SharedFile {
|
||||
buf: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const fp = picked[0].fsPath;
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
path: fp,
|
||||
name: path.basename(fp),
|
||||
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -77,13 +97,19 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
|
||||
if (msg.type === 'startShare') {
|
||||
stopActive();
|
||||
const filePath: string = msg.path;
|
||||
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
|
||||
if (!filePaths.length) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
const files: SharedFile[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
const fileBuf = fs.readFileSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
|
||||
}
|
||||
const token = crypto.randomBytes(6).toString('base64url');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
|
||||
@@ -93,8 +119,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
server: http.createServer(),
|
||||
port: 0,
|
||||
token,
|
||||
fileBuf,
|
||||
filename,
|
||||
files,
|
||||
};
|
||||
share.server.on('request', (req, res) => handleRequest(req, res, share));
|
||||
share.server.on('error', (err) => {
|
||||
@@ -125,7 +150,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
iface: ip.iface,
|
||||
url: `http://${ip.address}:${share.port}/s/${token}/`,
|
||||
}));
|
||||
panel.webview.postMessage({ type: 'sharing', urls, filename });
|
||||
panel.webview.postMessage({
|
||||
type: 'sharing',
|
||||
urls,
|
||||
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -141,6 +170,30 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
|
||||
if (req.method !== 'GET' || !req.url) {
|
||||
res.statusCode = 404;
|
||||
@@ -156,7 +209,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
|
||||
|
||||
if (rest === '' || rest === '/') {
|
||||
const body = chooserPage(share.filename);
|
||||
const body = chooserPage(share.files);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -165,24 +218,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
return;
|
||||
}
|
||||
|
||||
if (rest === '/view') {
|
||||
const viewMatch = rest.match(/^\/view\/(\d+)$/);
|
||||
if (rest === '/view' || viewMatch) {
|
||||
const index = viewMatch ? Number(viewMatch[1]) : 0;
|
||||
const file = share.files[index];
|
||||
if (!file) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rest === '/file') {
|
||||
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const fileMatch = rest.match(/^\/file\/(\d+)$/);
|
||||
if (rest === '/file' || fileMatch) {
|
||||
const index = fileMatch ? Number(fileMatch[1]) : 0;
|
||||
const file = share.files[index];
|
||||
if (!file) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||
'Content-Length': String(share.fileBuf.length),
|
||||
'Content-Length': String(file.buf.length),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,10 +259,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
res.end();
|
||||
}
|
||||
|
||||
function chooserPage(filename: string): string {
|
||||
const escName = filename.replace(/[&<>"]/g, (c) =>
|
||||
function chooserPage(files: SharedFile[]): string {
|
||||
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||
);
|
||||
const items = files.map((file, index) => {
|
||||
const escName = esc(file.filename);
|
||||
return `<div class="item">
|
||||
<div class="file">${escName}</div>
|
||||
<a class="btn view" href="view/${index}">View in browser</a>
|
||||
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -203,7 +280,8 @@ function chooserPage(filename: string): string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
||||
.item { margin-bottom: 26px; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -211,9 +289,7 @@ function chooserPage(filename: string): string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">${escName}</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="${escName}">Download .html</a>
|
||||
${items}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
@@ -250,26 +326,8 @@ function getHtml(qrScriptUri: string): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
.row { margin-bottom: 14px; }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.iface-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
||||
.iface-list label { font-size: 12px; cursor: pointer; }
|
||||
.iface-list .meta { opacity: 0.6; margin-left: 6px; }
|
||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: 4px; }
|
||||
${getToolWebviewStyles()}
|
||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
|
||||
#qr svg, #qr img { display: block; }
|
||||
.url {
|
||||
margin-top: 10px; padding: 8px;
|
||||
@@ -277,99 +335,168 @@ function getHtml(qrScriptUri: string): string {
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
|
||||
}
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; }
|
||||
#status { margin-top: 12px; min-height: 18px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; }
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.share-panel { padding: 12px; }
|
||||
.iface-radio { text-align: center; }
|
||||
#qr { margin-top: 0; }
|
||||
.selected-list { margin-top: 10px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
<script src="${qrScriptUri}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Send To Mobile</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<div class="file-cell">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span class="file-name" id="fileName">(no file)</span>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button id="start">Start sharing</button>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="start">Start</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" class="row" style="display:none;">
|
||||
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div class="iface-list" id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr"></div>
|
||||
<div class="url" id="urlText"></div>
|
||||
<div class="url-actions">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="ifaceWrap" class="control-group" style="display:none;">
|
||||
<div class="control-label">Network Interfaces</div>
|
||||
<p class="section-description">Pick the interface your phone can reach.</p>
|
||||
<div class="iface-list" id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" class="result-card share-panel" style="display:none;">
|
||||
<div id="qr"></div>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||
First run on Windows may show a firewall prompt - allow access for "Private networks".
|
||||
Sharing stops automatically when this panel is closed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedPaths = [];
|
||||
let selectedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', () => {
|
||||
statusEl.innerHTML = '';
|
||||
const path = filePathEl.value;
|
||||
if (!path) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
vscode.postMessage({ type: 'startShare', path });
|
||||
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
|
||||
});
|
||||
|
||||
stopBtn.addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'stopShare' });
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' });
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visibleFiles = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
visibleFiles.map((file, offset) => {
|
||||
const i = start + offset;
|
||||
return (
|
||||
'<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||
);
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.dataset.index);
|
||||
selectedFiles.splice(index, 1);
|
||||
selectedPaths.splice(index, 1);
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' });
|
||||
});
|
||||
if (selectedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
function renderQr(url) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -389,29 +516,42 @@ function getHtml(qrScriptUri: string): string {
|
||||
return;
|
||||
}
|
||||
ifaceWrap.style.display = '';
|
||||
ifaceList.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
const tbody = ifaceList.querySelector('tbody');
|
||||
urls.forEach((u, i) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + '<span class="meta">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td>' +
|
||||
'<td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'fileSelected') {
|
||||
filePathEl.value = m.path;
|
||||
fileNameEl.textContent = m.name;
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
|
||||
selectedPaths = selectedFiles.map(f => f.path);
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (m.type === 'sharing') {
|
||||
currentUrls = m.urls;
|
||||
selectedIdx = 0;
|
||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
shareInfo.style.display = '';
|
||||
renderIfaces(m.urls);
|
||||
renderQr(m.urls[0].url);
|
||||
@@ -420,6 +560,8 @@ function getHtml(qrScriptUri: string): string {
|
||||
} else if (m.type === 'stopped') {
|
||||
statusEl.textContent = 'Stopped.';
|
||||
shareInfo.style.display = 'none';
|
||||
ifaceWrap.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
currentUrls = [];
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
|
||||
@@ -13,6 +13,288 @@ export function handleClipboardAndOpen(msg: any): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getToolWebviewStyles(): string {
|
||||
return `
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--tool-gap-xs: 4px;
|
||||
--tool-gap-sm: 8px;
|
||||
--tool-gap-md: 12px;
|
||||
--tool-gap-lg: 16px;
|
||||
--tool-radius: 4px;
|
||||
--tool-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
|
||||
--tool-panel: var(--vscode-editorWidget-background, var(--vscode-sideBar-background));
|
||||
--tool-panel-soft: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
.tool-page {
|
||||
max-width: 1100px;
|
||||
padding: 18px;
|
||||
}
|
||||
.tool-header {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
}
|
||||
.tool-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-description,
|
||||
.section-description,
|
||||
.muted {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.tool-description {
|
||||
margin: 5px 0 0;
|
||||
max-width: 780px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.tool-section {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
}
|
||||
.tool-panel {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--tool-panel-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.is-hidden,
|
||||
.tool-panel.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--tool-gap-md);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--tool-border);
|
||||
background: var(--tool-panel);
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.45px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.control-group + .control-group {
|
||||
margin-top: var(--tool-gap-md);
|
||||
}
|
||||
.control-label {
|
||||
margin: 0 0 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-description {
|
||||
margin: -2px 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.field-row,
|
||||
.file-row,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tool-gap-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-row + .field-row {
|
||||
margin-top: var(--tool-gap-sm);
|
||||
}
|
||||
input[type=text],
|
||||
input[type=date],
|
||||
select,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, var(--tool-border));
|
||||
border-radius: var(--tool-radius);
|
||||
font: inherit;
|
||||
}
|
||||
input[type=text]:focus,
|
||||
input[type=date]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.field-row input[type=text] {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
button {
|
||||
min-height: 28px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border-color: var(--tool-border);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
button.danger {
|
||||
background: transparent;
|
||||
color: var(--vscode-errorForeground);
|
||||
border-color: var(--tool-border);
|
||||
}
|
||||
button.danger:hover {
|
||||
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.16));
|
||||
}
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.action-row {
|
||||
margin-top: var(--tool-gap-md);
|
||||
}
|
||||
.status-panel,
|
||||
.results-panel,
|
||||
.result-card {
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--tool-panel-soft);
|
||||
}
|
||||
.status-panel {
|
||||
min-height: 30px;
|
||||
margin-top: var(--tool-gap-md);
|
||||
padding: 8px 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.status-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
.results-panel {
|
||||
margin-top: var(--tool-gap-md);
|
||||
overflow: auto;
|
||||
}
|
||||
.results-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
.output-panel .results-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
.result-card {
|
||||
padding: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--tool-border);
|
||||
}
|
||||
.data-table th {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
background: var(--tool-panel);
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wrap {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.file-name {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ok {
|
||||
color: var(--vscode-testing-iconPassed, #3fb950);
|
||||
}
|
||||
.err {
|
||||
color: var(--vscode-errorForeground, #f85149);
|
||||
}
|
||||
.warn {
|
||||
color: var(--vscode-editorWarning-foreground, #d29922);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tool-border);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.ok {
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, transparent);
|
||||
background: rgba(63, 185, 80, 0.12);
|
||||
}
|
||||
.badge.err {
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f85149) 45%, transparent);
|
||||
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.12));
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #d29922) 45%, transparent);
|
||||
background: var(--vscode-inputValidation-warningBackground, rgba(210, 153, 34, 0.12));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function singletonPanel(
|
||||
store: { panel: vscode.WebviewPanel | null },
|
||||
viewType: string,
|
||||
|
||||
@@ -35,31 +35,50 @@ func pickApplovinUserAgent(randomize bool) string {
|
||||
|
||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
</header>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span id="fileName" class="hint">(no files)</span>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-files"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.qr-cell { text-align:center; }
|
||||
.qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
|
||||
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.qr-results:not(.has-errors) .error-col { display:none; }
|
||||
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
|
||||
.qr-modal.open { display:flex; }
|
||||
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
|
||||
</style>
|
||||
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPage = 0;
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
@@ -68,6 +87,7 @@ const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
let selectedPaths = [];
|
||||
let resultItems = [];
|
||||
|
||||
@@ -76,15 +96,38 @@ function basename(p) {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visiblePaths.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (selectedPaths.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', async () => {
|
||||
@@ -114,15 +157,27 @@ regenAllBtn.addEventListener('click', () => {
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
pickFolderBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.files && j.files.length) {
|
||||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
});
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.files && j.files.length) {
|
||||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||||
selectedPaths = selectedPaths.concat(added);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
});
|
||||
@@ -136,8 +191,8 @@ uploadBtn.addEventListener('click', async () => {
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/applovin/upload', {
|
||||
method: 'POST',
|
||||
@@ -174,24 +229,40 @@ function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
|
||||
|
||||
function handle(m) {
|
||||
if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||
qr.querySelector('img').addEventListener('click', (event) => {
|
||||
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||
qrModal.classList.add('open');
|
||||
});
|
||||
const actionCell = document.createElement('td');
|
||||
const error = document.createElement('td');
|
||||
error.className = 'error-col';
|
||||
error.innerHTML = '<span class="muted">-</span>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
@@ -218,13 +289,12 @@ function handle(m) {
|
||||
}
|
||||
};
|
||||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
}
|
||||
@@ -232,7 +302,7 @@ function handle(m) {
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
|
||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
|
||||
}
|
||||
|
||||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -253,6 +323,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
cfg.LastPickDir = dir
|
||||
_ = SaveConfig(cfg)
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Paths []string `json:"paths"`
|
||||
|
||||
@@ -12,83 +12,119 @@ import (
|
||||
|
||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
||||
.summary { opacity:0.8; margin-bottom:6px; }
|
||||
.asset-list { margin:0; padding-left:16px; }
|
||||
.asset-list li + li { margin-top:3px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const PAGE_SIZE = 5;
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
let selectedPage = 0;
|
||||
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!pickedFiles.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visible.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (pickedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addPaths(paths) {
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; }
|
||||
const payload = { files: pickedFiles };
|
||||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const flagged = j.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
||||
mark.textContent = rr.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = rr.file;
|
||||
row.appendChild(mark); row.appendChild(name);
|
||||
if (!rr.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + rr.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
const row = document.createElement('tr');
|
||||
const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
|
||||
row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -99,7 +135,11 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
|
||||
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
p := PickFolder("Select folder to scan", "")
|
||||
writeJSON(w, map[string]any{"path": p})
|
||||
if p == "" {
|
||||
writeJSON(w, map[string]any{"paths": []string{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
|
||||
}
|
||||
|
||||
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
116
standalone/changelog.go
Normal file
116
standalone/changelog.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ChangelogPage(w http.ResponseWriter, r *http.Request) {
|
||||
markdown := readChangelog()
|
||||
body := `
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Changelog</h2>
|
||||
<p class="tool-description">Loaded from CHANGELOG.md.</p>
|
||||
</header>
|
||||
<section class="tool-panel changelog-panel">
|
||||
<div class="panel-body changelog-content">
|
||||
` + renderChangelogMarkdown(markdown) + `
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.changelog-panel { max-width: 840px; }
|
||||
.changelog-content h1 { margin: 0 0 14px; font-size: 20px; line-height: 1.25; }
|
||||
.changelog-content h2 { margin: 18px 0 8px; padding-top: 12px; border-top: 1px solid #333; font-size: 15px; line-height: 1.3; }
|
||||
.changelog-content ul { margin: 6px 0 12px; padding-left: 20px; }
|
||||
.changelog-content li { margin: 4px 0; line-height: 1.45; }
|
||||
.changelog-content p { margin: 8px 0; line-height: 1.5; }
|
||||
</style>`
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/changelog", "Changelog", body)))
|
||||
}
|
||||
|
||||
func readChangelog() string {
|
||||
for _, start := range changelogSearchRoots() {
|
||||
for _, candidate := range changelogCandidates(start) {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "# Changelog\n\nCHANGELOG.md was not found."
|
||||
}
|
||||
|
||||
func changelogSearchRoots() []string {
|
||||
roots := []string{}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
roots = append(roots, wd)
|
||||
}
|
||||
roots = append(roots, appDir())
|
||||
return roots
|
||||
}
|
||||
|
||||
func changelogCandidates(start string) []string {
|
||||
candidates := []string{}
|
||||
dir := filepath.Clean(start)
|
||||
for i := 0; i < 5; i++ {
|
||||
candidates = append(candidates, filepath.Join(dir, "CHANGELOG.md"))
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func renderChangelogMarkdown(markdown string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
|
||||
var out strings.Builder
|
||||
inList := false
|
||||
closeList := func() {
|
||||
if inList {
|
||||
out.WriteString("</ul>")
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "### ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[4:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[3:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
closeList()
|
||||
out.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</h1>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
if !inList {
|
||||
out.WriteString("<ul>")
|
||||
inList = true
|
||||
}
|
||||
out.WriteString("<li>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</li>")
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
out.WriteString("<p>" + html.EscapeString(line) + "</p>")
|
||||
}
|
||||
closeList()
|
||||
return out.String()
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func DailyPage(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
lastProject := html.EscapeString(cfg.DailyUpdate.LastProject)
|
||||
|
||||
body := `
|
||||
<h2>Daily Update</h2>
|
||||
<div class="hint" style="margin-bottom:12px;">Submit copies the formatted text to your clipboard.</div>
|
||||
|
||||
<div style="max-width:520px;">
|
||||
<label style="display:block;margin-top:8px;font-size:12px;opacity:0.85;">Date</label>
|
||||
<input type="date" id="date" value="` + today + `" style="width:100%;" />
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Status</label>
|
||||
<select id="status" style="width:100%;">
|
||||
<option>Started</option><option>Progress</option><option>Finished</option>
|
||||
<option>Client Feedback</option><option>For OT</option>
|
||||
</select>
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Project</label>
|
||||
<input type="text" id="project" placeholder="Project name" value="` + lastProject + `" style="width:100%;" />
|
||||
|
||||
<label style="display:block;margin-top:12px;font-size:12px;opacity:0.85;">Remarks</label>
|
||||
<textarea id="remarks" placeholder="What did you do?" style="width:100%;min-height:96px;"></textarea>
|
||||
|
||||
<button id="submit" style="margin-top:16px;">Submit</button>
|
||||
<div id="status-msg" style="margin-top:12px;min-height:18px;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('submit').addEventListener('click', async () => {
|
||||
const payload = {
|
||||
date: document.getElementById('date').value,
|
||||
status: document.getElementById('status').value,
|
||||
project: document.getElementById('project').value.trim(),
|
||||
remarks: document.getElementById('remarks').value.trim(),
|
||||
};
|
||||
const statusEl = document.getElementById('status-msg');
|
||||
statusEl.textContent = 'Copying...';
|
||||
try {
|
||||
const res = await fetch('/api/daily/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.ok) {
|
||||
statusEl.innerHTML = '<span class="ok">Copied to clipboard.</span>';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="err">' + (j.error || 'Failed') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/daily", "Daily Update", body)))
|
||||
}
|
||||
|
||||
type dailySubmitReq struct {
|
||||
Date string `json:"date"`
|
||||
Status string `json:"status"`
|
||||
Project string `json:"project"`
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
|
||||
func DailySubmit(w http.ResponseWriter, r *http.Request) {
|
||||
var req dailySubmitReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
text := formatDailyMessage(req)
|
||||
if err := CopyToClipboard(text); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig()
|
||||
cfg.DailyUpdate.LastProject = req.Project
|
||||
_ = SaveConfig(cfg)
|
||||
writeJSON(w, map[string]any{"ok": true, "text": text})
|
||||
}
|
||||
|
||||
func formatDailyMessage(p dailySubmitReq) string {
|
||||
bullets := []string{}
|
||||
for _, line := range strings.Split(p.Remarks, "\n") {
|
||||
t := strings.TrimRight(line, "\r")
|
||||
t = regexp.MustCompile(`^\s*[-*]\s*`).ReplaceAllString(t, "")
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
bullets = append(bullets, "- "+t)
|
||||
}
|
||||
}
|
||||
lines := []string{
|
||||
"Date: " + formatDailyDate(p.Date),
|
||||
"Status: " + p.Status,
|
||||
"Project: " + p.Project,
|
||||
"Remarks:",
|
||||
strings.Join(bullets, "\n"),
|
||||
}
|
||||
if p.Status == "For OT" {
|
||||
lines = append(lines, "@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatDailyDate(iso string) string {
|
||||
t, err := time.Parse("2006-01-02", iso)
|
||||
if err != nil {
|
||||
return iso
|
||||
}
|
||||
return t.Format("01/02/2006")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -1,18 +1,51 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
var items strings.Builder
|
||||
for _, n := range visibleNavItems(cfg.BetaToolsEnabled) {
|
||||
if n.Path == "/" {
|
||||
continue
|
||||
}
|
||||
badge := ""
|
||||
if n.Beta {
|
||||
badge = ` <span class="beta-pill">Beta</span>`
|
||||
}
|
||||
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
||||
}
|
||||
|
||||
body := `
|
||||
<h2>HPL Toolbox</h2>
|
||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||
<ul style="line-height:1.9;">
|
||||
<li><a href="/plec" style="color:#9cdcfe;">PLEC Upload</a> — Upload HTML to the internal PLEC server</li>
|
||||
<li><a href="/applovin" style="color:#9cdcfe;">AppLovin Demo Upload</a> — Upload to p.applov.in (QR preview)</li>
|
||||
<li><a href="/base64" style="color:#9cdcfe;">Base64 Scanner</a> — Find non-base64 assets in HTML</li>
|
||||
<li><a href="/daily" style="color:#9cdcfe;">Daily Update</a> — Compose & copy a daily status</li>
|
||||
<li><a href="/mobile" style="color:#9cdcfe;">Send To Mobile</a> — Share HTML to a phone via LAN + QR</li>
|
||||
</ul>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">HPL Toolbox</h2>
|
||||
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
||||
</header>
|
||||
<div class="home-tools">
|
||||
` + items.String() + `
|
||||
</div>
|
||||
<style>
|
||||
.home-tools { display:flex; flex-direction:column; gap:8px; max-width:620px; }
|
||||
.home-tool {
|
||||
display:block;
|
||||
width:100%;
|
||||
min-height:52px;
|
||||
padding:9px 11px;
|
||||
color:#ddd;
|
||||
text-align:left;
|
||||
background:#2a2d2e;
|
||||
border:1px solid #333;
|
||||
border-radius:5px;
|
||||
cursor:pointer;
|
||||
font:inherit;
|
||||
}
|
||||
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
||||
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
||||
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||
</style>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/", "Home", body)))
|
||||
|
||||
11
standalone/json.go
Normal file
11
standalone/json.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SharedCSS = `
|
||||
:root { color-scheme: dark; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--tool-gap-sm: 8px;
|
||||
--tool-gap-md: 12px;
|
||||
--tool-gap-lg: 16px;
|
||||
--tool-radius: 5px;
|
||||
--tool-border: #333;
|
||||
--tool-panel: #252526;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1e1e1e; color: #ddd;
|
||||
margin: 0; padding: 0;
|
||||
@@ -14,12 +31,61 @@ const SharedCSS = `
|
||||
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||
.topbar a:hover { background: #2a2d2e; }
|
||||
.topbar a.active { background: #094771; color: #fff; }
|
||||
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
|
||||
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
|
||||
h2 { margin-top: 0; font-weight: 500; }
|
||||
.topbar .nav-link {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.topbar .nav-link:hover { background: #2a2d2e; }
|
||||
.topbar .nav-link.active { background: #094771; color: #fff; }
|
||||
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
|
||||
.topbar-spacer { margin-left: auto; }
|
||||
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
|
||||
.app-footer {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: auto auto 0;
|
||||
padding: 8px 18px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #a7a7a7;
|
||||
font-size: 10px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
.beta-tools-toggle {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #a7a7a7;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
opacity: 0.72;
|
||||
text-align: left;
|
||||
}
|
||||
.beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
|
||||
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
|
||||
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||
.tool-header { margin-bottom: var(--tool-gap-lg); }
|
||||
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||
.tool-description, .section-description, .muted { color: #a7a7a7; }
|
||||
.tool-description { margin: 5px 0 0; max-width: 780px; font-size: 12px; line-height: 1.45; }
|
||||
.tool-panel { margin-bottom: var(--tool-gap-lg); border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); overflow: hidden; }
|
||||
.panel-header { padding: 10px 12px; border-bottom: 1px solid var(--tool-border); background: var(--tool-panel); }
|
||||
.panel-title { margin: 0; color: #ddd; font-size: 12px; font-weight: 700; letter-spacing: 0.45px; text-transform: uppercase; }
|
||||
.panel-body { padding: 12px; }
|
||||
.control-group + .control-group { margin-top: var(--tool-gap-md); }
|
||||
.control-label { margin: 0 0 6px; color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; }
|
||||
.file-row, .field-row, .action-row { display: flex; align-items: center; gap: var(--tool-gap-sm); flex-wrap: wrap; }
|
||||
.action-row { margin-top: var(--tool-gap-md); }
|
||||
input[type=text], input[type=date], select, textarea {
|
||||
box-sizing: border-box; padding: 6px 8px;
|
||||
background: #3c3c3c; color: #ddd;
|
||||
@@ -36,39 +102,159 @@ const SharedCSS = `
|
||||
button:hover { background: #1177bb; }
|
||||
button.secondary { background: #3a3d41; color: #ddd; }
|
||||
button.secondary:hover { background: #45494e; }
|
||||
button.danger { background: transparent; color: #f48771; border: 1px solid #444; }
|
||||
button.danger:hover { background: rgba(244,135,113,0.14); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); }
|
||||
.status-panel { min-height: 30px; margin-top: var(--tool-gap-md); padding: 8px 10px; white-space: pre-wrap; }
|
||||
.status-panel:empty { display: none; }
|
||||
.results-panel { margin-top: var(--tool-gap-md); overflow: auto; }
|
||||
.results-panel:empty { display: none; }
|
||||
.result-card { padding: 10px; word-break: break-word; }
|
||||
.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--tool-border); }
|
||||
.data-table th { color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; background: var(--tool-panel); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; }
|
||||
.wrap { overflow-wrap: anywhere; word-break: break-word; }
|
||||
.file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.selected-list, .selected-files { margin-top: 8px; }
|
||||
.remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 18px; padding: 1px 6px; border-radius: 999px; border: 1px solid #444; font-size: 11px; line-height: 1.2; white-space: nowrap; }
|
||||
.badge.ok { color: #89d185; background: rgba(137,209,133,0.12); }
|
||||
.badge.err { color: #f48771; background: rgba(244,135,113,0.14); }
|
||||
.badge.warn { color: #ffd580; background: rgba(210,153,34,0.16); }
|
||||
.is-hidden { display: none !important; }
|
||||
.err { color: #f48771; white-space: pre-wrap; }
|
||||
.ok { color: #89d185; }
|
||||
.hint { font-size: 12px; opacity: 0.7; }
|
||||
`
|
||||
|
||||
type navItem struct{ Path, Label string }
|
||||
type navItem struct {
|
||||
Path string
|
||||
Label string
|
||||
Description string
|
||||
Beta bool
|
||||
}
|
||||
|
||||
var navItems = []navItem{
|
||||
{"/", "Home"},
|
||||
{"/plec", "PLEC Upload"},
|
||||
{"/applovin", "AppLovin Upload"},
|
||||
{"/base64", "Base64 Scanner"},
|
||||
{"/daily", "Daily Update"},
|
||||
{"/mobile", "Send To Mobile"},
|
||||
{Path: "/", Label: "Home"},
|
||||
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
|
||||
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
|
||||
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
|
||||
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
|
||||
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
|
||||
}
|
||||
|
||||
func visibleNavItems(betaToolsEnabled bool) []navItem {
|
||||
out := make([]navItem, 0, len(navItems))
|
||||
for _, n := range navItems {
|
||||
if n.Beta && !betaToolsEnabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return sortNavItemsForDisplay(out)
|
||||
}
|
||||
|
||||
func sortNavItemsForDisplay(items []navItem) []navItem {
|
||||
out := append([]navItem(nil), items...)
|
||||
for i := 0; i < len(out); i++ {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[i].Beta && !out[j].Beta {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func betaToolCount() int {
|
||||
count := 0
|
||||
for _, n := range navItems {
|
||||
if n.Beta {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func Page(activePath, title, body string) string {
|
||||
cfg := LoadConfig()
|
||||
betaToolsEnabled := cfg.BetaToolsEnabled
|
||||
var tabs strings.Builder
|
||||
for _, n := range navItems {
|
||||
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||
cls := ""
|
||||
if n.Path == activePath {
|
||||
cls = " class=\"active\""
|
||||
cls = " active"
|
||||
}
|
||||
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + n.Label + `</a>`)
|
||||
label := n.Label
|
||||
if n.Beta {
|
||||
label += `<span class="beta-pill">Beta</span>`
|
||||
}
|
||||
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`)
|
||||
}
|
||||
|
||||
betaButtonLabel := "Show beta"
|
||||
if betaToolsEnabled {
|
||||
betaButtonLabel = "Hide beta"
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>` + title + ` — HPL Toolbox</title>
|
||||
<style>` + SharedCSS + `</style>
|
||||
</head><body>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
|
||||
<div class="content">` + body + `</div>
|
||||
<div class="app-footer">
|
||||
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
||||
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
|
||||
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('[data-path]').forEach((element) => {
|
||||
element.addEventListener('click', () => {
|
||||
const path = element.dataset.path;
|
||||
if (path) window.location.href = path;
|
||||
});
|
||||
});
|
||||
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
await fetch('/api/betaTools', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
func boolAttr(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := LoadConfig()
|
||||
cfg.BetaToolsEnabled = req.Enabled
|
||||
if err := SaveConfig(cfg); err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -110,24 +110,26 @@ func buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("GET /plec", PlecPage)
|
||||
mux.HandleFunc("GET /applovin", ApplovinPage)
|
||||
mux.HandleFunc("GET /base64", Base64Page)
|
||||
mux.HandleFunc("GET /daily", DailyPage)
|
||||
mux.HandleFunc("GET /mraid", MraidPage)
|
||||
mux.HandleFunc("GET /mobile", MobilePage)
|
||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||
|
||||
// Shared API
|
||||
mux.HandleFunc("POST /api/clipboard", ClipboardEndpoint)
|
||||
mux.HandleFunc("POST /api/open", OpenEndpoint)
|
||||
mux.HandleFunc("POST /api/focus", FocusEndpoint)
|
||||
|
||||
// Daily Update
|
||||
mux.HandleFunc("POST /api/daily/submit", DailySubmit)
|
||||
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
|
||||
|
||||
// PLEC
|
||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
|
||||
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
||||
|
||||
// AppLovin
|
||||
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
||||
mux.HandleFunc("POST /api/applovin/pickFolder", ApplovinPickFolder)
|
||||
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
||||
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
||||
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||
@@ -137,11 +139,22 @@ func buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/base64/pickFiles", Base64PickFiles)
|
||||
mux.HandleFunc("POST /api/base64/scan", Base64Scan)
|
||||
|
||||
// MRAID Checker
|
||||
mux.HandleFunc("POST /api/mraid/pickFolder", MraidPickFolder)
|
||||
mux.HandleFunc("POST /api/mraid/pickFiles", MraidPickFiles)
|
||||
mux.HandleFunc("POST /api/mraid/scan", MraidScan)
|
||||
|
||||
// Send To Mobile
|
||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
|
||||
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||
|
||||
// Playworks Converter
|
||||
mux.HandleFunc("POST /api/playworks/pickSource", PlayworksPickSource)
|
||||
mux.HandleFunc("POST /api/playworks/pickOutput", PlayworksPickOutput)
|
||||
mux.HandleFunc("POST /api/playworks/convert", PlayworksConvert)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
@@ -27,7 +27,11 @@ type activeShare struct {
|
||||
listener net.Listener
|
||||
port int
|
||||
token string
|
||||
fileBuf []byte
|
||||
files []sharedFile
|
||||
}
|
||||
|
||||
type sharedFile struct {
|
||||
buf []byte
|
||||
filename string
|
||||
}
|
||||
|
||||
@@ -54,81 +58,143 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Send To Mobile</h2>
|
||||
<p class="hint" style="margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span id="fileName" class="hint">(no file)</span>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<button id="start">Start sharing</button>
|
||||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="start">Start</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
||||
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
|
||||
<div class="control-label">Network Interfaces</div>
|
||||
<div id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" class="result-card" style="display:none;">
|
||||
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||
First run on Windows may show a firewall prompt - allow access for Private networks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
||||
|
||||
<script src="/assets/qrcode.min.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedFiles = [];
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visible.map((file, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.dataset.index);
|
||||
selectedFiles.splice(index, 1);
|
||||
selectedPaths.splice(index, 1);
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (selectedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addFiles(files) {
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
|
||||
selectedPaths = selectedFiles.map(f => f.path);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
pickFolderBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
|
||||
const j = await r.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
filePathEl.value = j.path;
|
||||
fileNameEl.textContent = j.name;
|
||||
}
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedFiles = [];
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
statusEl.innerHTML = '';
|
||||
if (!filePathEl.value) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
const r = await fetch('/api/mobile/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ path: filePathEl.value }),
|
||||
body: JSON.stringify({ paths: selectedPaths }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
@@ -154,18 +220,8 @@ stopBtn.addEventListener('click', async () => {
|
||||
stopBtn.disabled = true;
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
||||
});
|
||||
|
||||
function renderQr(url) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -182,19 +238,18 @@ function renderIfaces(urls) {
|
||||
ifaceList.innerHTML = '';
|
||||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||||
ifaceWrap.style.display = '';
|
||||
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
|
||||
const tbody = ifaceList.querySelector('tbody');
|
||||
urls.forEach((u, i) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.style.fontSize = '12px';
|
||||
label.style.cursor = 'pointer';
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -207,13 +262,31 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles(
|
||||
"Select HTML",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
false, "",
|
||||
true, "",
|
||||
)
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{"path": nil})
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
||||
files := make([]map[string]string, 0, len(picked))
|
||||
for _, p := range picked {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
dir := PickFolder("Select folder", "")
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -221,21 +294,33 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Path == "" || !fileExists(req.Path) {
|
||||
paths := req.Paths
|
||||
if len(paths) == 0 && req.Path != "" {
|
||||
paths = []string{req.Path}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(req.Path)
|
||||
files := make([]sharedFile, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if p == "" || !fileExists(p) {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(req.Path)
|
||||
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
|
||||
}
|
||||
|
||||
// 6 random bytes -> base64url, no padding
|
||||
tokenBytes := make([]byte, 6)
|
||||
@@ -257,8 +342,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
listener: listener,
|
||||
port: port,
|
||||
token: token,
|
||||
fileBuf: fileBuf,
|
||||
filename: filename,
|
||||
files: files,
|
||||
}
|
||||
share.server = &http.Server{Handler: shareHandler(share)}
|
||||
|
||||
@@ -288,6 +372,10 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
||||
})
|
||||
}
|
||||
filename := fmt.Sprintf("%d files", len(files))
|
||||
if len(files) == 1 {
|
||||
filename = files[0].filename
|
||||
}
|
||||
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
|
||||
}
|
||||
|
||||
@@ -308,19 +396,53 @@ func shareHandler(s *activeShare) http.Handler {
|
||||
case "", "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(chooserPage(s.filename)))
|
||||
_, _ = w.Write([]byte(chooserPage(s.files)))
|
||||
case "/view":
|
||||
fallthrough
|
||||
case "/view/0":
|
||||
if len(s.files) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
case "/file":
|
||||
safeName := safeFilename(s.filename)
|
||||
fallthrough
|
||||
case "/file/0":
|
||||
if len(s.files) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
safeName := safeFilename(s.files[0].filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
default:
|
||||
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
if len(parts) == 2 {
|
||||
idx, err := strconv.Atoi(parts[1])
|
||||
if err == nil && idx >= 0 && idx < len(s.files) {
|
||||
file := s.files[idx]
|
||||
if parts[0] == "view" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(file.buf)
|
||||
return
|
||||
}
|
||||
safeName := safeFilename(file.filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(file.buf)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(file.buf)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
@@ -330,8 +452,14 @@ var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||
|
||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||
|
||||
func chooserPage(filename string) string {
|
||||
escName := html.EscapeString(filename)
|
||||
func chooserPage(files []sharedFile) string {
|
||||
var items strings.Builder
|
||||
for i, file := range files {
|
||||
escName := html.EscapeString(file.filename)
|
||||
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
|
||||
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
|
||||
}
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -341,7 +469,8 @@ func chooserPage(filename string) string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
||||
.item { margin-bottom: 26px; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -349,9 +478,7 @@ func chooserPage(filename string) string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
||||
` + items.String() + `
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
|
||||
463
standalone/mraid.go
Normal file
463
standalone/mraid.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mraidIssue struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
type mraidResult struct {
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Issues []mraidIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type mraidRule struct {
|
||||
ID string
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
Reference string
|
||||
Test func(mraidContext, mraidRule) *mraidIssue
|
||||
}
|
||||
|
||||
type mraidContext struct {
|
||||
HTML string
|
||||
ScriptText string
|
||||
MraidCallLines map[string]int
|
||||
}
|
||||
|
||||
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
</header>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:20px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
|
||||
.issues { margin:6px 0 0 28px; padding:0; }
|
||||
.issue { list-style:none; margin:6px 0; }
|
||||
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
|
||||
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
|
||||
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
|
||||
.issue-title { font-weight:600; }
|
||||
.issue-detail { opacity:0.72; margin-top:2px; }
|
||||
.summary { opacity:0.85; margin-bottom:6px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!pickedFiles.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||||
visible.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (pickedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addPaths(paths) {
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; }
|
||||
const payload = { files: pickedFiles };
|
||||
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json();
|
||||
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const withIssues = j.results.filter(r => !r.ok).length;
|
||||
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(j.skipped || 0);
|
||||
const skippedReason = j.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = rr.file;
|
||||
const result = document.createElement('td');
|
||||
result.className = 'wrap';
|
||||
if (!rr.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
for (const i of rr.issues) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
result.appendChild(list);
|
||||
} else {
|
||||
result.innerHTML = '<span class="badge ok">OK</span>';
|
||||
}
|
||||
row.append(file, result);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
|
||||
}
|
||||
|
||||
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
p := PickFolder("Select folder to scan", "")
|
||||
if p == "" {
|
||||
writeJSON(w, map[string]any{"paths": []string{}})
|
||||
return
|
||||
}
|
||||
files := []string{}
|
||||
for _, f := range collectHTMLFiles(p) {
|
||||
if isInSupportedMraidFolderPath(f) {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{"paths": files})
|
||||
}
|
||||
|
||||
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
paths := PickFiles("Select HTML files", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "")
|
||||
writeJSON(w, map[string]any{"paths": paths})
|
||||
}
|
||||
|
||||
func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var targets []string
|
||||
var baseDir string
|
||||
skipped := 0
|
||||
skippedReason := ""
|
||||
noTargetsError := "No HTML files were found."
|
||||
if len(req.Files) > 0 {
|
||||
var existing []string
|
||||
for _, f := range req.Files {
|
||||
if fileExists(f) {
|
||||
existing = append(existing, f)
|
||||
}
|
||||
}
|
||||
targets = existing
|
||||
skipped = len(req.Files) - len(existing)
|
||||
skippedReason = "missing file(s)."
|
||||
noTargetsError = "No selected HTML files were found."
|
||||
if len(targets) > 0 {
|
||||
baseDir = commonBaseDir(targets)
|
||||
}
|
||||
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||
all := collectHTMLFiles(req.Folder)
|
||||
for _, f := range all {
|
||||
if isInSupportedMraidFolderPath(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(all) - len(targets)
|
||||
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
|
||||
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError})
|
||||
return
|
||||
}
|
||||
|
||||
results := []mraidResult{}
|
||||
for _, file := range targets {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
issues := checkMraid(string(data))
|
||||
display := file
|
||||
if baseDir != "" {
|
||||
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
|
||||
display = rel
|
||||
} else {
|
||||
display = filepath.Base(file)
|
||||
}
|
||||
}
|
||||
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason})
|
||||
}
|
||||
|
||||
var (
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
|
||||
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
|
||||
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
|
||||
)
|
||||
|
||||
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||
|
||||
func isInSupportedMraidFolderPath(filePath string) bool {
|
||||
supported := map[string]bool{
|
||||
"al": true,
|
||||
"applovin": true,
|
||||
"app-lovin": true,
|
||||
"is": true,
|
||||
"ironsource": true,
|
||||
"iron-source": true,
|
||||
"iron_source": true,
|
||||
"un": true,
|
||||
"unity": true,
|
||||
"unityads": true,
|
||||
"unity-ads": true,
|
||||
"unity_ads": true,
|
||||
}
|
||||
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
})
|
||||
for _, part := range parts {
|
||||
if supported[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMraid(htmlSrc string) []mraidIssue {
|
||||
ctx := mraidContext{
|
||||
HTML: htmlSrc,
|
||||
ScriptText: extractMraidScriptText(htmlSrc),
|
||||
MraidCallLines: collectMraidCallLines(htmlSrc),
|
||||
}
|
||||
var out []mraidIssue
|
||||
for _, rule := range mraidRules {
|
||||
if found := rule.Test(ctx, rule); found != nil {
|
||||
out = append(out, *found)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []mraidIssue{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractMraidScriptText(htmlSrc string) string {
|
||||
var parts []string
|
||||
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
|
||||
parts = append(parts, m[1])
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func collectMraidCallLines(htmlSrc string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for idx, line := range strings.Split(htmlSrc, "\n") {
|
||||
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
|
||||
if _, ok := out[m[1]]; !ok {
|
||||
out[m[1]] = idx + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
|
||||
func mraidHasCall(ctx mraidContext, method string) bool {
|
||||
_, ok := ctx.MraidCallLines[method]
|
||||
return ok
|
||||
}
|
||||
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
|
||||
|
||||
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
|
||||
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
|
||||
return rx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidHasReadyGate(ctx mraidContext) bool {
|
||||
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
|
||||
}
|
||||
|
||||
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
|
||||
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
|
||||
if line > 0 {
|
||||
found.Line = line
|
||||
}
|
||||
return &found
|
||||
}
|
||||
|
||||
var mraidRules = []mraidRule{
|
||||
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasReference(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "viewability", Severity: "best-practice", Title: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
|
||||
return nil
|
||||
}
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}},
|
||||
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
|
||||
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
|
||||
if depends && !mraidHasEventListener(ctx, "sizeChange") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
|
||||
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
|
||||
if changes && !mraidHasEventListener(ctx, "stateChange") {
|
||||
return mraidIssueFrom(rule, 0)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type PlecConfig struct {
|
||||
@@ -27,15 +28,11 @@ type SendToMobileConfig struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type DailyUpdateConfig struct {
|
||||
LastProject string `json:"lastProject"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Plec PlecConfig `json:"plec"`
|
||||
Applovin ApplovinConfig `json:"applovin"`
|
||||
SendToMobile SendToMobileConfig `json:"sendToMobile"`
|
||||
DailyUpdate DailyUpdateConfig `json:"dailyUpdate"`
|
||||
BetaToolsEnabled bool `json:"betaToolsEnabled"`
|
||||
LastPickDir string `json:"lastPickDir,omitempty"`
|
||||
}
|
||||
|
||||
@@ -68,7 +65,6 @@ func defaultConfig() AppConfig {
|
||||
},
|
||||
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
|
||||
SendToMobile: SendToMobileConfig{Port: 0},
|
||||
DailyUpdate: DailyUpdateConfig{LastProject: ""},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +115,12 @@ func SaveConfig(cfg AppConfig) error {
|
||||
|
||||
func OpenInBrowser(url string) {
|
||||
// `start` is a cmd builtin. Quoted empty arg is the window title.
|
||||
cmd := exec.Command("cmd", "/c", "start", "", url)
|
||||
cmd := hiddenCommand("cmd", "/c", "start", "", url)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func CopyToClipboard(text string) error {
|
||||
cmd := exec.Command("clip")
|
||||
cmd := hiddenCommand("clip")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -136,6 +132,15 @@ type FileFilter struct {
|
||||
|
||||
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
|
||||
func hiddenCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
|
||||
filterStr := "All files|*.*"
|
||||
if len(filters) > 0 {
|
||||
@@ -164,7 +169,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -203,7 +208,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.FileName
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -220,7 +225,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.SelectedPath
|
||||
}
|
||||
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
434
standalone/playworks.go
Normal file
434
standalone/playworks.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type playworksConvertOptions struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
BaseName string `json:"baseName"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type playworksNetworkResult struct {
|
||||
Network string `json:"network"`
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Playworks Converter</h2>
|
||||
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
|
||||
.net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
|
||||
.net-label { min-width:100px; font-weight:500; }
|
||||
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||
.net-note { font-size:11px; opacity:0.6; }
|
||||
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||
</style>
|
||||
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div class="field-row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||
<button id="pickOut" class="secondary">Select Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll" class="secondary">All</button>
|
||||
<button id="selectNone" class="secondary">None</button>
|
||||
</div>
|
||||
<div class="net-grid">
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="is" checked /><span class="net-label">Ironsource <span class="net-tag">is</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML (window.top to self)</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="fb" checked /><span class="net-label">Facebook <span class="net-tag">fb</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="gg" checked /><span class="net-label">Google Ads <span class="net-tag">gg</span></span><span class="net-note">ZIP + exitapi.js in head</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
<button id="openOut" class="secondary" disabled>Open Output Folder</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<script>
|
||||
let outputDir = '';
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickSource', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
srcEl.value = j.path;
|
||||
baseNameEl.value = j.baseName || '';
|
||||
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
|
||||
updateConvertBtn();
|
||||
}
|
||||
});
|
||||
document.getElementById('pickOut').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||
});
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
document.getElementById('convert').addEventListener('click', async () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
const res = await fetch('/api/playworks/convert', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks })
|
||||
});
|
||||
const msg = await res.json();
|
||||
convertBtn.disabled = false;
|
||||
updateConvertBtn();
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
if (!results.length) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of results) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td class="mono">' + r.network + '</td>' +
|
||||
'<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
|
||||
'<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body)))
|
||||
}
|
||||
|
||||
func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "")
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0]), "baseName": playworksSourceBaseName(picked[0])})
|
||||
}
|
||||
|
||||
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")})
|
||||
}
|
||||
|
||||
func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
|
||||
var opts playworksConvertOptions
|
||||
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !fileExists(opts.SourcePath) {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."})
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."})
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(opts.SourcePath)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := []playworksNetworkResult{}
|
||||
for _, network := range opts.Networks {
|
||||
filePath, err := convertPlayworksNetwork(string(data), opts, network)
|
||||
if err != nil {
|
||||
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$`)
|
||||
|
||||
func playworksSourceBaseName(sourcePath string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
|
||||
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||
}
|
||||
|
||||
func playworksOutputBaseName(opts playworksConvertOptions) string {
|
||||
baseName := strings.TrimSpace(opts.BaseName)
|
||||
if baseName == "" {
|
||||
baseName = playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
baseName = filepath.Base(baseName)
|
||||
baseName = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, baseName)
|
||||
baseName = strings.TrimSpace(baseName)
|
||||
if baseName == "" {
|
||||
return playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
return baseName
|
||||
}
|
||||
|
||||
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||
baseName := playworksOutputBaseName(opts)
|
||||
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if playworksNeedsZip(network) {
|
||||
zipPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return zipPath, nil
|
||||
}
|
||||
|
||||
outPath := filepath.Join(outputDir, htmlFileName)
|
||||
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func playworksNetworkOutputFolder(network string) string {
|
||||
switch network {
|
||||
case "al":
|
||||
return "Applovin"
|
||||
case "is":
|
||||
return "Ironsource"
|
||||
case "un":
|
||||
return "Unity"
|
||||
case "fb":
|
||||
return "Facebook"
|
||||
case "gg":
|
||||
return "GoogleAds"
|
||||
case "mo":
|
||||
return "Moloco"
|
||||
case "vu":
|
||||
return "Vungle"
|
||||
case "mtg":
|
||||
return "Mintegral"
|
||||
case "tt":
|
||||
return "TikTok"
|
||||
default:
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
func playworksNeedsZip(network string) bool {
|
||||
return network == "gg" || network == "vu" || network == "mtg"
|
||||
}
|
||||
|
||||
func transformPlayworksHTML(htmlSrc, network string) string {
|
||||
result := htmlSrc
|
||||
headInject := buildPlayworksLifecycleScript()
|
||||
if network == "al" || network == "is" {
|
||||
headInject += `<script src="mraid.js"></script>`
|
||||
} else if network == "gg" {
|
||||
headInject += `<script src="exitapi.js"></script>`
|
||||
}
|
||||
result = strings.Replace(result, "</head>", headInject+"\n</head>", 1)
|
||||
|
||||
if network == "vu" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__VUNGLE__=true;</script>`, 1)
|
||||
} else if network == "mtg" {
|
||||
result = strings.Replace(result, "<body>", `<body onload="gameReady()">`, 1)
|
||||
} else if network == "tt" {
|
||||
result = strings.Replace(result, "<body>", `<body>
|
||||
<script>window.__TIKTOK__=true;</script>`, 1)
|
||||
}
|
||||
|
||||
result = replacePlayworksCTAScript(result, network)
|
||||
if network == "un" {
|
||||
result = strings.ReplaceAll(result, "window.top", "window.self")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func replacePlayworksCTAScript(htmlSrc, network string) string {
|
||||
marker := "Luna.Unity.Playable.InstallFullGame=function"
|
||||
markerIdx := strings.LastIndex(htmlSrc, marker)
|
||||
if markerIdx == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "<script>")
|
||||
if scriptOpenIdx == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptCloseRel := strings.Index(htmlSrc[markerIdx:], "</script>")
|
||||
if scriptCloseRel == -1 {
|
||||
return htmlSrc
|
||||
}
|
||||
scriptCloseIdx := markerIdx + scriptCloseRel
|
||||
return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len("</script>"):]
|
||||
}
|
||||
|
||||
func buildPlayworksConsoleRestoreScript() string {
|
||||
return strings.Join([]string{
|
||||
`<script>(function(){`,
|
||||
`try{`,
|
||||
`var f=document.createElement("iframe");`,
|
||||
`f.style.display="none";`,
|
||||
`document.documentElement.appendChild(f);`,
|
||||
`var nc=f.contentWindow.console;`,
|
||||
`document.documentElement.removeChild(f);`,
|
||||
`var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];`,
|
||||
`methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});`,
|
||||
`}catch(e){}`,
|
||||
`})();</script>`,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksLifecycleScript() string {
|
||||
return strings.Join([]string{
|
||||
`<script>(function(){`,
|
||||
`if(typeof window.gameReady!=="function")window.gameReady=function(){};`,
|
||||
`if(typeof window.gameStart!=="function")window.gameStart=function(){};`,
|
||||
`if(typeof window.gameEnd!=="function")window.gameEnd=function(){};`,
|
||||
`if(typeof window.gameClose!=="function")window.gameClose=function(){};`,
|
||||
`var _grOnce=false,_gsOnce=false;`,
|
||||
`window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});`,
|
||||
`window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});`,
|
||||
`window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});`,
|
||||
`})();</script>`,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func buildPlayworksCTAScript(network string) string {
|
||||
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
|
||||
closeCall := `try{window.gameClose();}catch(e){}`
|
||||
ctaLogic := closeCall + `window.open(o,"_blank");`
|
||||
switch network {
|
||||
case "al", "is":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){if(typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",function(){mraid.open(o)});}else{mraid.open(o);}}else{window.open(o,"_blank");}`
|
||||
case "un":
|
||||
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){mraid.open(o);}else{window.open(o,"_blank");}`
|
||||
case "fb":
|
||||
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`
|
||||
case "gg":
|
||||
ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`
|
||||
case "mo":
|
||||
ctaLogic = closeCall + `if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`
|
||||
case "vu":
|
||||
ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}`
|
||||
case "mtg":
|
||||
ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`
|
||||
case "tt":
|
||||
ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`
|
||||
}
|
||||
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),` + urlSetup + ctaLogic + `}}))}));</script>`
|
||||
}
|
||||
|
||||
func createSingleFileZip(data []byte, nameInZip, destZipPath string) error {
|
||||
_ = os.Remove(destZipPath)
|
||||
out, err := os.Create(destZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
entry, err := zw.Create(nameInZip)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := entry.Write(data); err != nil {
|
||||
_ = zw.Close()
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
@@ -17,42 +17,54 @@ import (
|
||||
|
||||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>PLEC Upload</h2>
|
||||
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">PLEC Upload</h2>
|
||||
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||
</header>
|
||||
|
||||
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
||||
<thead><tr>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results"></div>
|
||||
<button id="openResult" class="secondary" disabled>Open</button>
|
||||
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
|
||||
.file-cell { display:flex; align-items:center; gap:8px; }
|
||||
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
|
||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result a { color:#9cdcfe; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; }
|
||||
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
|
||||
#rows input[type=text] { width: 100%; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const rowsPanel = document.getElementById('rowsPanel');
|
||||
const emptySelection = document.getElementById('emptySelection');
|
||||
const openResultBtn = document.getElementById('openResult');
|
||||
const copyResultBtn = document.getElementById('copyResult');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\.html?$/i, '');
|
||||
@@ -63,65 +75,101 @@ function parseIterationName(filename) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = id;
|
||||
tr.innerHTML = ` + "`" + `
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<button class="pick secondary">Choose...</button>
|
||||
<span class="file-name" data-name>(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" data-path />
|
||||
<div class="row-error" data-error></div>
|
||||
</td>
|
||||
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
|
||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
||||
` + "`" + `;
|
||||
tr.querySelector('.pick').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
if (j.files && j.files.length) {
|
||||
const affected = [];
|
||||
j.files.forEach(function(f, i) {
|
||||
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
|
||||
target.querySelector('[data-path]').value = f.path;
|
||||
target.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = target.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(target);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function addFiles(files) {
|
||||
const existing = new Set(rows.map(r => r.path));
|
||||
const before = rows.length;
|
||||
(files || []).forEach(f => {
|
||||
if (existing.has(f.path)) return;
|
||||
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
|
||||
existing.add(f.path);
|
||||
});
|
||||
const added = rows.length - before;
|
||||
if (added > 1) {
|
||||
const batch = rows.slice(rows.length - added);
|
||||
const first = batch[0].iteration;
|
||||
if (first && batch.every(r => r.iteration === first)) {
|
||||
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
|
||||
}
|
||||
}
|
||||
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
renderRows();
|
||||
}
|
||||
function renderRows() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||
if (!rows.length) { tbody.innerHTML = ''; return; }
|
||||
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
page = Math.min(page, maxPage);
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr data-id="' + row.id + '">' +
|
||||
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
|
||||
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
|
||||
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
|
||||
});
|
||||
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
|
||||
});
|
||||
if (rows.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
rowsPanel.after(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||
}
|
||||
});
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
if (!tbody.children.length) addRow();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
function setPreview(url) {
|
||||
previewUrl = url || '';
|
||||
openResultBtn.disabled = !previewUrl;
|
||||
copyResultBtn.disabled = !previewUrl;
|
||||
}
|
||||
|
||||
document.getElementById('addRow').addEventListener('click', addRow);
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
rows = [];
|
||||
page = 0;
|
||||
setPreview('');
|
||||
statusEl.textContent = '';
|
||||
renderRows();
|
||||
});
|
||||
openResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
|
||||
});
|
||||
copyResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
|
||||
});
|
||||
|
||||
document.getElementById('upload').addEventListener('click', async () => {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
setPreview('');
|
||||
if (rows.length === 0) {
|
||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
||||
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Uploading...';
|
||||
@@ -133,9 +181,10 @@ document.getElementById('upload').addEventListener('click', async () => {
|
||||
const j = await res.json();
|
||||
if (j.rowErrors) {
|
||||
for (const re of j.rowErrors) {
|
||||
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = re.message;
|
||||
const row = rows.find(r => r.id === re.id);
|
||||
if (row) row.error = re.message;
|
||||
}
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
return;
|
||||
}
|
||||
@@ -144,40 +193,10 @@ document.getElementById('upload').addEventListener('click', async () => {
|
||||
return;
|
||||
}
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (j.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = j.rawText;
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(j.preview);
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -204,6 +223,23 @@ func PlecPick(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func PlecPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
cfg.LastPickDir = dir
|
||||
_ = SaveConfig(cfg)
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
type plecRow struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
|
||||
Reference in New Issue
Block a user