bridgerator 1.0.7 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -15
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +465 -43
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +743 -29
- package/src/cli.mjs +561 -53
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +86 -23
- package/src/pair.mjs +41 -6
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -109
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Extra tools" / photos). Photo details (EXIF), smaller / converted
|
|
2
|
+
// copies and renaming by the date taken — with the imaging built into Windows (photos.ps1, WIC).
|
|
3
|
+
// Never overwrites: a converted photo is always a NEW file; renaming goes through the engine's
|
|
4
|
+
// move_file (ctx.engine), so the folder rules, confirmations and snapshots / Undo apply as for any move.
|
|
5
|
+
// GPS position is returned only when the call asks for it (gps: true).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { psJson, guard, visiblePath, fail, json, needAction, str, int, freeName } from './tool-lib.mjs';
|
|
10
|
+
|
|
11
|
+
const PS = fileURLToPath(new URL('./photos.ps1', import.meta.url));
|
|
12
|
+
const ACTIONS = ['info', 'convert', 'rename_by_date'];
|
|
13
|
+
export const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.jpe', '.png', '.tif', '.tiff', '.heic', '.heif', '.bmp', '.gif', '.webp', '.jxr', '.wdp']);
|
|
14
|
+
const MAX_FILES = 500, MAX_DEPTH = 6, MAX_DIRS = 2000; // folders visited: a huge tree must not freeze the bridge
|
|
15
|
+
|
|
16
|
+
export const defs = [{
|
|
17
|
+
name: 'photos',
|
|
18
|
+
title: 'Photos',
|
|
19
|
+
description: 'Photos on this PC. action "info": date taken, camera, size, orientation (GPS position only with gps: true); "convert": smaller or converted copies (max_size, quality, format jpg/png) saved as new files, originals untouched; "rename_by_date": names like "2026-07-14 10.22.33.jpg" from the date taken — a preview by default, dry_run: false renames (undoable). path = a photo or a folder.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object', additionalProperties: false, required: ['action', 'path'],
|
|
22
|
+
properties: {
|
|
23
|
+
action: { type: 'string', enum: ACTIONS },
|
|
24
|
+
path: { type: 'string', description: 'A photo or a folder (full path)' },
|
|
25
|
+
recursive: { type: 'boolean', description: 'Include subfolders' },
|
|
26
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_FILES, description: 'At most this many photos (default 200)' },
|
|
27
|
+
gps: { type: 'boolean', description: 'info: include the GPS position' },
|
|
28
|
+
max_size: { type: 'integer', minimum: 16, maximum: 20000, description: 'convert: longest side in pixels (default 1920)' },
|
|
29
|
+
quality: { type: 'integer', minimum: 1, maximum: 100, description: 'convert: JPEG quality (default 85)' },
|
|
30
|
+
format: { type: 'string', enum: ['jpg', 'png'], description: 'convert: default jpg' },
|
|
31
|
+
output: { type: 'string', description: 'convert: folder (or file for one photo) for the new copies; default: next to each photo as "name (small).jpg"' },
|
|
32
|
+
dry_run: { type: 'boolean', description: 'rename_by_date: true (default) only shows the plan' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
36
|
+
}];
|
|
37
|
+
|
|
38
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o), 'utf8').toString('base64');
|
|
39
|
+
const isImage = (p) => IMAGE_EXT.has(path.extname(p).toLowerCase());
|
|
40
|
+
|
|
41
|
+
/** The photos at `p` (a file or a folder), each under the folder rules; → { files, truncated, hidden }. */
|
|
42
|
+
export function listPhotos(ctx, p, { recursive = false, limit = 200 } = {}) {
|
|
43
|
+
const root = guard(ctx, p);
|
|
44
|
+
let st;
|
|
45
|
+
try { st = fs.statSync(root); } catch { fail('NOT_FOUND', `"${root}" was not found.`, 'Check the path.'); }
|
|
46
|
+
if (st.isFile()) { if (!isImage(root)) fail('NOT_PHOTO', `"${path.basename(root)}" is not a photo this tool reads (${[...IMAGE_EXT].join(' ')}).`); return { files: [root], truncated: false, hidden: 0 }; }
|
|
47
|
+
const files = [];
|
|
48
|
+
let hidden = 0, truncated = false;
|
|
49
|
+
const stack = [[root, 0]];
|
|
50
|
+
let dirs = 0;
|
|
51
|
+
while (stack.length && !truncated) {
|
|
52
|
+
if (++dirs > MAX_DIRS) { truncated = true; break; }
|
|
53
|
+
const [dir, depth] = stack.pop();
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
56
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
57
|
+
const subdirs = [];
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
const full = path.join(dir, e.name);
|
|
60
|
+
if (e.isDirectory()) { if (recursive && depth < MAX_DEPTH) subdirs.push(full); continue; }
|
|
61
|
+
if (!e.isFile() || !isImage(full)) continue;
|
|
62
|
+
if (!visiblePath(ctx, full)) { hidden++; continue; }
|
|
63
|
+
if (files.length >= limit) { truncated = true; break; }
|
|
64
|
+
files.push(full);
|
|
65
|
+
}
|
|
66
|
+
for (const d of subdirs.reverse()) stack.push([d, depth + 1]);
|
|
67
|
+
}
|
|
68
|
+
return { files, truncated, hidden };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const runPs = (ctx, mode, req, n, per) => psJson(ctx, { files: [PS], env: { BRX_MODE: mode }, input: b64(req), timeout: 30000 + n * per });
|
|
72
|
+
|
|
73
|
+
/** "2026:07:14 10:22:33" → { iso: '2026-07-14 10:22:33', name: '2026-07-14 10.22.33' } or null. */
|
|
74
|
+
export function takenOf(s) {
|
|
75
|
+
const m = /^(\d{4})[:-](\d{2})[:-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(String(s || '').trim());
|
|
76
|
+
if (!m || m[1] === '0000' || +m[2] < 1 || +m[2] > 12 || +m[3] < 1 || +m[3] > 31 || +m[4] > 23 || +m[5] > 59 || +m[6] > 59) return null;
|
|
77
|
+
return { iso: `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`, name: `${m[1]}-${m[2]}-${m[3]} ${m[4]}.${m[5]}.${m[6]}` };
|
|
78
|
+
}
|
|
79
|
+
const ERR = { NO_CODEC: 'Windows has no decoder for this format (for HEIC: install "HEIF Image Extensions" from the Microsoft Store)', DECODE_FAILED: 'not a readable image', NOT_FOUND: 'file not found', NO_ACCESS: 'Windows denied access', IO: 'the file is in use or unreadable', EXISTS: 'a file with the new name already exists' };
|
|
80
|
+
const why = (code) => ERR[code] || String(code || 'unknown problem');
|
|
81
|
+
|
|
82
|
+
async function info(ctx, files, gps) {
|
|
83
|
+
const r = await runPs(ctx, 'info', { files, gps }, files.length, 1000);
|
|
84
|
+
return (r.items || []).map((x) => {
|
|
85
|
+
if (x.error) return { path: x.path, problem: why(x.error) };
|
|
86
|
+
const t = takenOf(x.taken);
|
|
87
|
+
const camera = [x.make, x.model].filter(Boolean).join(' ').replace(/^(\S+) \1 /i, '$1 ');
|
|
88
|
+
return {
|
|
89
|
+
path: x.path, ...(t ? { taken: t.iso } : {}), ...(camera ? { camera } : {}), width: x.width, height: x.height,
|
|
90
|
+
...(x.orientation && x.orientation !== 1 ? { orientation: x.orientation } : {}), ...(x.format ? { format: x.format } : {}),
|
|
91
|
+
// GPS: only when asked for (also enforced here, whatever the script returned).
|
|
92
|
+
...(gps && x.gps ? { gps: x.gps } : x.hasGps ? { location: 'stored in the photo (not shown; ask with gps: true)' } : {}),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function convertPlan(ctx, files, { output, format }) {
|
|
98
|
+
const ext = format === 'png' ? '.png' : '.jpg';
|
|
99
|
+
let outDir = null, outFile = null;
|
|
100
|
+
if (output !== undefined) {
|
|
101
|
+
const o = guard(ctx, output, { write: true });
|
|
102
|
+
const isDir = (() => { try { return fs.statSync(o).isDirectory(); } catch { return false; } })();
|
|
103
|
+
if (isDir || /[\\/]$/.test(output) || files.length > 1 || !path.extname(o)) outDir = o;
|
|
104
|
+
else outFile = o;
|
|
105
|
+
if (outDir && !fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
const taken = new Set();
|
|
108
|
+
const free = (p) => { let c = freeName(p); const { dir, name, ext: e } = path.parse(p); for (let i = 2; taken.has(c.toLowerCase()); i++) c = freeName(path.join(dir, `${name} (${i})${e}`)); taken.add(c.toLowerCase()); return c; };
|
|
109
|
+
return files.map((src) => {
|
|
110
|
+
const base = path.parse(src).name;
|
|
111
|
+
const dst = outFile ? outFile : outDir ? path.join(outDir, `${base}${ext}`) : path.join(path.dirname(src), `${base} (small)${ext}`);
|
|
112
|
+
return { src, dst: free(guard(ctx, dst, { write: true })) };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renamePlan(ctx, infos) {
|
|
117
|
+
const taken = new Set();
|
|
118
|
+
const plan = [], skipped = [];
|
|
119
|
+
for (const x of infos) {
|
|
120
|
+
if (x.problem) { skipped.push({ path: x.path, reason: x.problem }); continue; }
|
|
121
|
+
const t = takenOf(x.taken ? x.taken.replace(/^(\d{4})-(\d{2})-(\d{2})/, '$1:$2:$3') : '');
|
|
122
|
+
if (!t) { skipped.push({ path: x.path, reason: 'no date taken in the photo' }); continue; }
|
|
123
|
+
const { dir, name, ext } = path.parse(x.path);
|
|
124
|
+
if (name === t.name || new RegExp(`^${t.name.replace(/\./g, '\\.')} \\(\\d+\\)$`).test(name)) { skipped.push({ path: x.path, reason: 'already named by date' }); continue; }
|
|
125
|
+
try { guard(ctx, x.path, { write: true }); } catch (e) { skipped.push({ path: x.path, reason: e.message }); continue; }
|
|
126
|
+
let to = path.join(dir, `${t.name}${ext}`);
|
|
127
|
+
for (let i = 2; fs.existsSync(to) || taken.has(to.toLowerCase()); i++) to = path.join(dir, `${t.name} (${i})${ext}`);
|
|
128
|
+
taken.add(to.toLowerCase());
|
|
129
|
+
plan.push({ from: x.path, to });
|
|
130
|
+
}
|
|
131
|
+
return { plan, skipped };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const textOf = (r) => (r?.content || []).filter((c) => c?.type === 'text').map((c) => c.text).join(' ').slice(0, 300);
|
|
135
|
+
|
|
136
|
+
export async function handle(name, args, ctx) {
|
|
137
|
+
const action = needAction(args, ACTIONS);
|
|
138
|
+
const p = str(args, 'path', { required: true, max: 1000 });
|
|
139
|
+
const limit = int(args, 'limit', { min: 1, max: MAX_FILES, def: 200 });
|
|
140
|
+
const { files, truncated, hidden } = listPhotos(ctx, p, { recursive: args.recursive === true, limit });
|
|
141
|
+
const extra = { ...(truncated ? { truncated: `only the first ${limit} photos` } : {}), ...(hidden ? { hidden: `${hidden} photo(s) hidden by this PC's folder rules` } : {}) };
|
|
142
|
+
if (!files.length) return json({ count: 0, note: 'No photos found here.', ...extra });
|
|
143
|
+
|
|
144
|
+
if (action === 'info') {
|
|
145
|
+
const items = await info(ctx, files, args.gps === true);
|
|
146
|
+
return json({ count: items.length, photos: items, ...extra });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (action === 'convert') {
|
|
150
|
+
const format = args.format === undefined ? 'jpg' : args.format;
|
|
151
|
+
if (!['jpg', 'png'].includes(format)) fail('BAD_ARGS', 'format must be jpg or png.');
|
|
152
|
+
// max_size (snake_case like dry_run); the older spelling maxSize is still read.
|
|
153
|
+
const maxSize = int(args, args.max_size !== undefined ? 'max_size' : 'maxSize', { min: 16, max: 20000, def: 1920 });
|
|
154
|
+
const quality = int(args, 'quality', { min: 1, max: 100, def: 85 });
|
|
155
|
+
const output = str(args, 'output', { max: 1000 });
|
|
156
|
+
// Copies made earlier ("name (small).jpg") are not converted again.
|
|
157
|
+
const fresh = files.length > 1 ? files.filter((f) => !/ \(small\)( \(\d+\))?$/i.test(path.parse(f).name)) : files;
|
|
158
|
+
if (!fresh.length) return json({ converted: 0, note: 'Only copies made earlier ("… (small)") are here.', ...extra });
|
|
159
|
+
const items = convertPlan(ctx, fresh, { output, format });
|
|
160
|
+
const r = await runPs(ctx, 'convert', { items, maxSize, quality, format }, items.length, 3000);
|
|
161
|
+
const done = [], failed = [];
|
|
162
|
+
for (const x of r.items || []) {
|
|
163
|
+
if (x.ok) done.push({ from: x.src, saved: x.dst, width: x.width, height: x.height, bytes: x.bytes });
|
|
164
|
+
else failed.push({ path: x.src, problem: why(x.error) });
|
|
165
|
+
}
|
|
166
|
+
return json({ converted: done.length, files: done, ...(failed.length ? { failed } : {}), note: 'Originals were not changed. The new files carry no metadata (no GPS); photos are turned upright.', ...extra });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// rename_by_date
|
|
170
|
+
const infos = await info(ctx, files, false);
|
|
171
|
+
const { plan, skipped } = renamePlan(ctx, infos);
|
|
172
|
+
if (args.dry_run !== false) return json({ preview: true, rename: plan, ...(skipped.length ? { skipped } : {}), note: plan.length ? 'Nothing renamed yet. Call again with dry_run: false to rename.' : 'Nothing to rename.', ...extra });
|
|
173
|
+
if (!ctx.engine) fail('NO_ENGINE', 'Renaming is not available here.');
|
|
174
|
+
const renamed = [], failed = [];
|
|
175
|
+
for (const it of plan) {
|
|
176
|
+
// The plan was made before the awaits: a file may have appeared under the chosen name meanwhile.
|
|
177
|
+
if (fs.existsSync(it.to)) {
|
|
178
|
+
const { dir, ext } = path.parse(it.to), stem = path.parse(it.to).name.replace(/ \(\d+\)$/, '');
|
|
179
|
+
let i = 2;
|
|
180
|
+
while (fs.existsSync(path.join(dir, `${stem} (${i})${ext}`)) || plan.some((o) => o !== it && o.to.toLowerCase() === path.join(dir, `${stem} (${i})${ext}`).toLowerCase())) i++;
|
|
181
|
+
it.to = path.join(dir, `${stem} (${i})${ext}`);
|
|
182
|
+
}
|
|
183
|
+
const r = await ctx.engine('move_file', { source: it.from, destination: it.to });
|
|
184
|
+
if (r?.isError) { failed.push({ path: it.from, problem: textOf(r) || 'not renamed' }); if (r.notConfirmed || r.paused) break; } else renamed.push(it);
|
|
185
|
+
}
|
|
186
|
+
return json({ renamed: renamed.length, files: renamed, ...(failed.length ? { failed } : {}), ...(skipped.length ? { skipped } : {}), ...(renamed.length ? { undo: 'Renames can be undone with bridgerator_undo.' } : {}), ...extra });
|
|
187
|
+
}
|
package/src/photos.ps1
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# LOGIC: see critical-logic/bridge.md ("Extra tools" / photos). Photo metadata and conversion with the
|
|
2
|
+
# imaging built into Windows (WIC through PresentationCore) - no extra programs.
|
|
3
|
+
# In: BRX_MODE = info | convert; stdin = base64 of UTF-8 JSON:
|
|
4
|
+
# info: { files: [path...], gps: bool }
|
|
5
|
+
# convert: { items: [{ src, dst }], maxSize: int (0 = keep), quality: 1-100, format: 'jpg' | 'png' }
|
|
6
|
+
# Tags are read by numeric id (EXIF 36867 DateTimeOriginal, 271 Make, 272 Model, 274 Orientation,
|
|
7
|
+
# GPS 1-4), never by localized names. A new file is created with CreateNew: nothing is overwritten.
|
|
8
|
+
# Out: ONE JSON line { items: [...] }; per-file problems are codes (NO_CODEC, DECODE_FAILED, EXISTS...).
|
|
9
|
+
Add-Type -AssemblyName PresentationCore, WindowsBase
|
|
10
|
+
function Out-Json($o) { [Console]::Out.WriteLine(($o | ConvertTo-Json -Compress -Depth 6)) }
|
|
11
|
+
$req = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(([Console]::In.ReadToEnd()).Trim())) | ConvertFrom-Json
|
|
12
|
+
|
|
13
|
+
function Q($md, $q) { try { return $md.GetQuery($q) } catch { return $null } }
|
|
14
|
+
function Get-Prefix($md) {
|
|
15
|
+
if (-not $md) { return $null }
|
|
16
|
+
foreach ($p in @('/app1/ifd', '/ifd')) { if ((Q $md "$p/{ushort=271}") -ne $null -or (Q $md "$p/exif") -ne $null -or (Q $md "$p/{ushort=274}") -ne $null) { return $p } }
|
|
17
|
+
return $null
|
|
18
|
+
}
|
|
19
|
+
function Err-Code($e) {
|
|
20
|
+
$h = $e.Exception.HResult
|
|
21
|
+
if ($e.Exception.InnerException) { $h = $e.Exception.InnerException.HResult }
|
|
22
|
+
if ($h -eq -2003292336) { return 'NO_CODEC' } # 0x88982F50 WINCODEC_ERR_COMPONENTNOTFOUND
|
|
23
|
+
if ($e.Exception -is [System.IO.FileNotFoundException]) { return 'NOT_FOUND' }
|
|
24
|
+
if ($e.Exception -is [System.UnauthorizedAccessException]) { return 'NO_ACCESS' }
|
|
25
|
+
if ($e.Exception -is [System.IO.IOException]) { return 'IO' }
|
|
26
|
+
return 'DECODE_FAILED'
|
|
27
|
+
}
|
|
28
|
+
function Open-Frame($path, $cache) {
|
|
29
|
+
$fs = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
|
|
30
|
+
try {
|
|
31
|
+
$dec = [Windows.Media.Imaging.BitmapDecoder]::Create($fs, [Windows.Media.Imaging.BitmapCreateOptions]::PreservePixelFormat -bor [Windows.Media.Imaging.BitmapCreateOptions]::IgnoreColorProfile, $cache)
|
|
32
|
+
return @{ fs = $fs; dec = $dec; frame = $dec.Frames[0] }
|
|
33
|
+
} catch { $fs.Dispose(); throw }
|
|
34
|
+
}
|
|
35
|
+
function Rationals($v) {
|
|
36
|
+
if ($v -eq $null) { return $null }
|
|
37
|
+
$out = @()
|
|
38
|
+
foreach ($x in @($v)) {
|
|
39
|
+
$u = [uint64]$x
|
|
40
|
+
$num = [double]($u -band [uint64]4294967295); $den = [double]($u -shr 32)
|
|
41
|
+
$out += $(if ($den -eq 0) { 0 } else { $num / $den })
|
|
42
|
+
}
|
|
43
|
+
return $out
|
|
44
|
+
}
|
|
45
|
+
function Deg($v, $ref) {
|
|
46
|
+
$r = Rationals $v
|
|
47
|
+
if (-not $r -or $r.Count -lt 1) { return $null }
|
|
48
|
+
$d = $r[0]; if ($r.Count -gt 1) { $d += $r[1] / 60 }; if ($r.Count -gt 2) { $d += $r[2] / 3600 }
|
|
49
|
+
if ("$ref" -match '^[SW]') { $d = -$d }
|
|
50
|
+
return [math]::Round($d, 6)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ($env:BRX_MODE -eq 'info') {
|
|
54
|
+
$items = New-Object System.Collections.ArrayList
|
|
55
|
+
foreach ($p in @($req.files)) {
|
|
56
|
+
$o = @{ path = [string]$p }
|
|
57
|
+
$h = $null
|
|
58
|
+
try {
|
|
59
|
+
$h = Open-Frame $p ([Windows.Media.Imaging.BitmapCacheOption]::None)
|
|
60
|
+
$f = $h.frame
|
|
61
|
+
$o.width = $f.PixelWidth; $o.height = $f.PixelHeight
|
|
62
|
+
$md = $f.Metadata -as [Windows.Media.Imaging.BitmapMetadata]
|
|
63
|
+
if ($md) { try { $o.format = [string]$md.Format } catch { } }
|
|
64
|
+
$pre = Get-Prefix $md
|
|
65
|
+
if ($pre) {
|
|
66
|
+
$dt = Q $md "$pre/exif/{ushort=36867}"
|
|
67
|
+
if (-not $dt) { $dt = Q $md "$pre/exif/{ushort=36868}" }
|
|
68
|
+
if (-not $dt) { $dt = Q $md "$pre/{ushort=306}" }
|
|
69
|
+
if ($dt) { $o.taken = ([string]$dt).Trim([char]0, ' ') }
|
|
70
|
+
$mk = Q $md "$pre/{ushort=271}"; if ($mk) { $o.make = ([string]$mk).Trim([char]0, ' ') }
|
|
71
|
+
$ml = Q $md "$pre/{ushort=272}"; if ($ml) { $o.model = ([string]$ml).Trim([char]0, ' ') }
|
|
72
|
+
$or = Q $md "$pre/{ushort=274}"; if ($or -ne $null) { $o.orientation = [int]$or }
|
|
73
|
+
$gl = Q $md "$pre/gps/{ushort=2}"
|
|
74
|
+
if ($gl -ne $null) {
|
|
75
|
+
$o.hasGps = $true
|
|
76
|
+
if ($req.gps -eq $true) {
|
|
77
|
+
$o.gps = @{ lat = (Deg $gl (Q $md "$pre/gps/{ushort=1}")); lon = (Deg (Q $md "$pre/gps/{ushort=4}") (Q $md "$pre/gps/{ushort=3}")) }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch { $o.error = Err-Code $_ } finally { if ($h) { $h.fs.Dispose() } }
|
|
82
|
+
[void]$items.Add($o)
|
|
83
|
+
}
|
|
84
|
+
Out-Json @{ items = @($items) }
|
|
85
|
+
exit 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ($env:BRX_MODE -eq 'convert') {
|
|
89
|
+
$max = [int]$req.maxSize; $q = [int]$req.quality; if ($q -lt 1 -or $q -gt 100) { $q = 85 }
|
|
90
|
+
$items = New-Object System.Collections.ArrayList
|
|
91
|
+
foreach ($it in @($req.items)) {
|
|
92
|
+
$o = @{ src = [string]$it.src; dst = [string]$it.dst }
|
|
93
|
+
$h = $null; $out = $null
|
|
94
|
+
try {
|
|
95
|
+
$h = Open-Frame $it.src ([Windows.Media.Imaging.BitmapCacheOption]::OnLoad)
|
|
96
|
+
$f = $h.frame
|
|
97
|
+
$md = $f.Metadata -as [Windows.Media.Imaging.BitmapMetadata]
|
|
98
|
+
$pre = Get-Prefix $md
|
|
99
|
+
$orient = 1; if ($pre) { $v = Q $md "$pre/{ushort=274}"; if ($v -ne $null) { $orient = [int]$v } }
|
|
100
|
+
$w = $f.PixelWidth; $hgt = $f.PixelHeight
|
|
101
|
+
$s = 1.0; $big = [math]::Max($w, $hgt)
|
|
102
|
+
if ($max -gt 0 -and $big -gt $max) { $s = $max / $big }
|
|
103
|
+
$tg = New-Object Windows.Media.TransformGroup
|
|
104
|
+
if ($s -ne 1.0) { $tg.Children.Add((New-Object Windows.Media.ScaleTransform $s, $s)) }
|
|
105
|
+
# EXIF orientation applied to the pixels (the new file carries no metadata, so it stays upright).
|
|
106
|
+
switch ($orient) {
|
|
107
|
+
2 { $tg.Children.Add((New-Object Windows.Media.ScaleTransform -1, 1)) }
|
|
108
|
+
3 { $tg.Children.Add((New-Object Windows.Media.RotateTransform 180)) }
|
|
109
|
+
4 { $tg.Children.Add((New-Object Windows.Media.ScaleTransform 1, -1)) }
|
|
110
|
+
5 { $tg.Children.Add((New-Object Windows.Media.RotateTransform 90)); $tg.Children.Add((New-Object Windows.Media.ScaleTransform -1, 1)) }
|
|
111
|
+
6 { $tg.Children.Add((New-Object Windows.Media.RotateTransform 90)) }
|
|
112
|
+
7 { $tg.Children.Add((New-Object Windows.Media.RotateTransform 270)); $tg.Children.Add((New-Object Windows.Media.ScaleTransform -1, 1)) }
|
|
113
|
+
8 { $tg.Children.Add((New-Object Windows.Media.RotateTransform 270)) }
|
|
114
|
+
}
|
|
115
|
+
$img = [Windows.Media.Imaging.BitmapSource]$f
|
|
116
|
+
if ($tg.Children.Count -gt 0) { $img = New-Object Windows.Media.Imaging.TransformedBitmap $img, $tg }
|
|
117
|
+
if ($req.format -eq 'png') { $enc = New-Object Windows.Media.Imaging.PngBitmapEncoder }
|
|
118
|
+
else {
|
|
119
|
+
$enc = New-Object Windows.Media.Imaging.JpegBitmapEncoder; $enc.QualityLevel = $q
|
|
120
|
+
# JPEG has no transparency: flatten onto white (else transparent parts turn black). Formats with
|
|
121
|
+
# alpha: Bgra32 / Pbgra32 / Rgba64 / Prgba64 / Rgba128Float...; Indexed* may carry a transparent colour.
|
|
122
|
+
if ([string]$img.Format -cmatch '^(Bgra|Pbgra|Rgba|Prgba|Indexed)') {
|
|
123
|
+
$pw = $img.PixelWidth; $ph = $img.PixelHeight
|
|
124
|
+
$dv = New-Object Windows.Media.DrawingVisual; $dc = $dv.RenderOpen()
|
|
125
|
+
$rect = New-Object Windows.Rect 0, 0, $pw, $ph
|
|
126
|
+
$dc.DrawRectangle([Windows.Media.Brushes]::White, $null, $rect); $dc.DrawImage($img, $rect); $dc.Close()
|
|
127
|
+
$rtb = New-Object Windows.Media.Imaging.RenderTargetBitmap $pw, $ph, 96, 96, ([Windows.Media.PixelFormats]::Pbgra32)
|
|
128
|
+
$rtb.Render($dv)
|
|
129
|
+
$img = New-Object Windows.Media.Imaging.FormatConvertedBitmap $rtb, ([Windows.Media.PixelFormats]::Bgr24), $null, 0
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
$enc.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($img))
|
|
133
|
+
$out = [IO.File]::Open($it.dst, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
134
|
+
$enc.Save($out); $out.Dispose(); $out = $null
|
|
135
|
+
$o.ok = $true; $o.width = $img.PixelWidth; $o.height = $img.PixelHeight; $o.bytes = (Get-Item -LiteralPath $it.dst).Length
|
|
136
|
+
} catch {
|
|
137
|
+
$o.error = $(if ($_.Exception -is [System.IO.IOException] -and (Test-Path -LiteralPath $it.dst) -and -not $out) { 'EXISTS' } else { Err-Code $_ })
|
|
138
|
+
if ($out) { $out.Dispose(); Remove-Item -LiteralPath $it.dst -Force -ErrorAction SilentlyContinue }
|
|
139
|
+
} finally { if ($h) { $h.fs.Dispose() } }
|
|
140
|
+
[void]$items.Add($o)
|
|
141
|
+
}
|
|
142
|
+
Out-Json @{ items = @($items) }
|
|
143
|
+
exit 0
|
|
144
|
+
}
|
|
145
|
+
Out-Json @{ error = 'BAD_MODE' }
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The plan as the relay reports it ({type:'plan', plan, until?, calls, limit, month, renewUrl}) →
|
|
2
|
+
// what the Bridgerator window, the tray and our notifications show. Paying is offered only here on
|
|
3
|
+
// the PC (never inside ChatGPT). Links: only our own site.
|
|
4
|
+
export const ACCOUNT_URL = 'https://bridgerator.recondeck.com/app';
|
|
5
|
+
const DAY = 86400000;
|
|
6
|
+
// Our site = the one this PC is linked to (derived from the relay address: staging stays on staging).
|
|
7
|
+
const PROD = 'https://bridgerator.recondeck.com';
|
|
8
|
+
let site = PROD;
|
|
9
|
+
/** Sets the site (https origin only; anything else keeps the previous one). */
|
|
10
|
+
export function setSite(s) {
|
|
11
|
+
try { const x = new URL(String(s)); if (x.protocol === 'https:' && !x.username && !x.password) site = x.origin; } catch { /* keep */ }
|
|
12
|
+
}
|
|
13
|
+
export const siteUrl = () => site;
|
|
14
|
+
export const accountUrl = () => `${site}/app`;
|
|
15
|
+
|
|
16
|
+
/** Only https://<our site>/… — anything else falls back to the account page. */
|
|
17
|
+
export function safeUrl(u) {
|
|
18
|
+
try { const x = new URL(String(u)); if (x.protocol === 'https:' && (x.origin === site || x.origin === PROD) && !x.username && !x.password) return x.href; } catch { /* bad */ }
|
|
19
|
+
return accountUrl();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const int = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : null);
|
|
23
|
+
|
|
24
|
+
/** Cleans the relay message → { plan:'free'|'pro', until?, calls?, limit?, month?, renewUrl }. */
|
|
25
|
+
export function readPlan(m = {}) {
|
|
26
|
+
const until = typeof m.until === 'string' && !Number.isNaN(Date.parse(m.until)) ? new Date(m.until).toISOString() : undefined;
|
|
27
|
+
return {
|
|
28
|
+
plan: m.plan === 'pro' ? 'pro' : 'free',
|
|
29
|
+
...(until ? { until } : {}),
|
|
30
|
+
...(int(m.calls) !== null ? { calls: m.calls } : {}),
|
|
31
|
+
...(int(m.limit) ? { limit: m.limit } : {}),
|
|
32
|
+
...(typeof m.month === 'string' && /^\d{4}-\d{2}$/.test(m.month) ? { month: m.month } : {}),
|
|
33
|
+
renewUrl: safeUrl(m.renewUrl),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** First day of the month after `month` (YYYY-MM) or after `now`, as YYYY-MM-DD. */
|
|
38
|
+
export function resetDay(month, now = Date.now()) {
|
|
39
|
+
const [y, mo] = month ? month.split('-').map(Number) : [new Date(now).getUTCFullYear(), new Date(now).getUTCMonth() + 1];
|
|
40
|
+
return new Date(Date.UTC(y, mo, 1)).toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* → { kind, tone, dismissible, days?, calls?, limit?, reset?, action:'renew'|'upgrade', url } or
|
|
45
|
+
* { kind:'none' }. Kinds: pro_ending (≤3 days left), pro_ended (ended within 30 days), limit (Free
|
|
46
|
+
* limit reached), free_high (Free ≥ 80 %).
|
|
47
|
+
*/
|
|
48
|
+
export function planBanner(p, now = Date.now()) {
|
|
49
|
+
if (!p) return { kind: 'none' };
|
|
50
|
+
const url = p.renewUrl || accountUrl();
|
|
51
|
+
const left = p.until ? Date.parse(p.until) - now : null;
|
|
52
|
+
if (p.plan === 'pro' && left !== null) {
|
|
53
|
+
if (left <= 0) return { kind: 'pro_ended', tone: 'red', dismissible: true, action: 'renew', url };
|
|
54
|
+
const days = Math.ceil(left / DAY);
|
|
55
|
+
if (days <= 3) return { kind: 'pro_ending', tone: 'yellow', dismissible: true, days, action: 'renew', url };
|
|
56
|
+
return { kind: 'none' };
|
|
57
|
+
}
|
|
58
|
+
if (p.plan === 'free' && left !== null && left <= 0 && -left < 30 * DAY) return { kind: 'pro_ended', tone: 'red', dismissible: true, action: 'renew', url };
|
|
59
|
+
if (p.plan === 'free' && p.limit && p.calls !== undefined) {
|
|
60
|
+
if (p.calls >= p.limit) return { kind: 'limit', tone: 'red', dismissible: false, calls: p.calls, limit: p.limit, reset: resetDay(p.month, now), action: 'upgrade', url };
|
|
61
|
+
if (p.calls >= 0.8 * p.limit) return { kind: 'free_high', tone: 'yellow', dismissible: true, calls: p.calls, limit: p.limit, action: 'upgrade', url };
|
|
62
|
+
}
|
|
63
|
+
return { kind: 'none' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Usage this month and a simple forecast for the Status page (never shown in ChatGPT): the average
|
|
68
|
+
* pace of the days gone this month (at least one day) × the days of the month. → null (no numbers) or
|
|
69
|
+
* { calls, limit?, state: 'unlimited' | 'enough' | 'runs_out' | 'out', projected?, date? (YYYY-MM-DD) }.
|
|
70
|
+
*/
|
|
71
|
+
export function forecast(p, now = Date.now()) {
|
|
72
|
+
if (!p || p.calls === undefined) return null;
|
|
73
|
+
if (p.plan === 'pro' || !p.limit) return { calls: p.calls, state: 'unlimited' };
|
|
74
|
+
if (p.calls >= p.limit) return { calls: p.calls, limit: p.limit, state: 'out' };
|
|
75
|
+
const [y, mo] = p.month ? p.month.split('-').map(Number) : [new Date(now).getUTCFullYear(), new Date(now).getUTCMonth() + 1];
|
|
76
|
+
const start = Date.UTC(y, mo - 1, 1), end = Date.UTC(y, mo, 1), days = (end - start) / DAY;
|
|
77
|
+
const gone = Math.max(1, (Math.min(Math.max(now, start), end) - start) / DAY);
|
|
78
|
+
const perDay = p.calls / gone, projected = Math.round(perDay * days);
|
|
79
|
+
if (projected <= p.limit) return { calls: p.calls, limit: p.limit, state: 'enough', projected };
|
|
80
|
+
const date = new Date(Math.min(end - DAY, start + (p.limit / perDay) * DAY)).toISOString().slice(0, 10);
|
|
81
|
+
return { calls: p.calls, limit: p.limit, state: 'runs_out', projected, date };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A dismissed banner stays hidden 24 h (same kind only); the limit banner can't be dismissed. */
|
|
85
|
+
export function bannerVisible(b, dismissed, now = Date.now()) {
|
|
86
|
+
if (b.kind === 'none') return false;
|
|
87
|
+
if (!b.dismissible) return true;
|
|
88
|
+
return !(dismissed && dismissed.kind === b.kind && now < dismissed.until);
|
|
89
|
+
}
|
|
90
|
+
export const dismissFor = (b, now = Date.now()) => ({ kind: b.kind, until: now + DAY });
|