sourcemonkey 0.0.0-stage → 0.5.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.
Files changed (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,63 @@
1
+ // The small square "copy this to the clipboard" button that sits
2
+ // beside a path.
3
+ //
4
+ // Shared because it appears in more than one place — the viewer's
5
+ // Location row and each entry in its Local overrides row, and the
6
+ // delete dialog's file path — and they must not drift into different
7
+ // icons, different flash durations, or different failure behavior.
8
+ // Its CSS (`.copy-btn`) is in styles.css for the same reason.
9
+ //
10
+ // The text is supplied as a callback rather than a string: a button
11
+ // can outlive the render that made it (the Location row's is one
12
+ // long-lived element moved between renders, so an in-flight "Copied"
13
+ // flash survives a repaint), and it must copy what the row says NOW,
14
+ // not what it said when the button was built.
15
+ // The copy / check glyphs. Both live in the shared sprite
16
+ // (src/glyph-sprite.svg) and are referenced here rather than inlined,
17
+ // so every icon in the extension is defined in one place. Safe to
18
+ // embed via innerHTML: no interpolation, and the strings ship as part
19
+ // of the extension bundle.
20
+ //
21
+ // Both are always in the button; `.copied` on the button swaps which
22
+ // one is displayed, so the flash needs no re-render.
23
+ const COPY_ICON_SVG = '<svg class="copy-icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#sm-copy"/></svg>';
24
+ const CHECK_ICON_SVG = '<svg class="check-icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#sm-check"/></svg>';
25
+ // How long the checkmark stays up after a successful copy.
26
+ const COPIED_FLASH_MS = 1200;
27
+ export function makeCopyButton(options) {
28
+ const btn = document.createElement('button');
29
+ btn.type = 'button';
30
+ btn.className = 'copy-btn';
31
+ btn.title = options.label;
32
+ btn.setAttribute('aria-label', options.label);
33
+ btn.innerHTML = COPY_ICON_SVG + CHECK_ICON_SVG;
34
+ // Per-button timer, so two buttons flashing at once don't cancel each
35
+ // other, and a rapid double-click on one leaves no stuck checkmark:
36
+ // each click clears its own prior reset and schedules a new one.
37
+ let resetTimer = null;
38
+ btn.addEventListener('click', () => {
39
+ void (async () => {
40
+ const text = options.text();
41
+ if (!text)
42
+ return;
43
+ try {
44
+ await navigator.clipboard.writeText(text);
45
+ }
46
+ catch {
47
+ // The write can be rejected (permission denied, or no user
48
+ // gesture credited). The text is selectable on the page, so
49
+ // there is nothing to fall back to and nothing worth saying.
50
+ return;
51
+ }
52
+ // `.copied` swaps the visible icon to a checkmark via CSS.
53
+ btn.classList.add('copied');
54
+ if (resetTimer !== null)
55
+ clearTimeout(resetTimer);
56
+ resetTimer = setTimeout(() => {
57
+ btn.classList.remove('copied');
58
+ resetTimer = null;
59
+ }, COPIED_FLASH_MS);
60
+ })();
61
+ });
62
+ return btn;
63
+ }
@@ -0,0 +1,502 @@
1
+ // The "make a local copy of this script" dialog, opened from the Local
2
+ // copy button on the viewer's Collection row.
3
+ //
4
+ // It owns the whole action, the way the move dialog owns Move: the
5
+ // file name and header fields, the refusals, the write through
6
+ // `makeLocalCopy`, and any failure in its own error slot — so the
7
+ // caller only ever hears about a copy that landed.
8
+ //
9
+ // See docs/local-copy.md.
10
+ import { checkExistingFiles, COPY_OVER_ITSELF, defaultCopyName, dependencyFiles, downloadsCopiesDir, DOWNLOADS_COPIES_NAME, DOWNLOADS_COPIES_SUBDIR, findDownloadsCollection, isCopyOfItself, makeLocalCopy, planFiles, readDownloadsCopiesSetting, validateCopyName, } from './local-copy.js';
11
+ import { effectiveDisplayName } from './collection-display.js';
12
+ import { isFileSchemeAccessAllowed, renderFileAccessBanner } from './file-access.js';
13
+ import { formatBytes } from '../script-display.js';
14
+ import { collectionUsesLocalFiles, describeError, getCollections, } from './script-store.js';
15
+ import { getValue } from '../userscript-header.js';
16
+ import { localEditVersion } from '../version-compare.js';
17
+ import { dlog } from '../debug-log.js';
18
+ let elements = null;
19
+ // One at a time, claimed synchronously — same reason as the other
20
+ // dialogs' guards: two overlapping opens would leave the first caller's
21
+ // promise unsettled and `showModal` throwing on the second.
22
+ let dialogInFlight = false;
23
+ function el(tag, className, text) {
24
+ const node = document.createElement(tag);
25
+ if (className)
26
+ node.className = className;
27
+ if (text !== undefined)
28
+ node.textContent = text;
29
+ return node;
30
+ }
31
+ // A one-line box that grows with its content, to the cap the CSS sets
32
+ // (four lines) and then scrolls. A `<textarea>` rather than an
33
+ // `<input>` because the value can be a sentence, but it still holds ONE
34
+ // line of a metadata block — see `sanitizeOneLine`.
35
+ function growingInput(id) {
36
+ const box = document.createElement('textarea');
37
+ box.id = id;
38
+ box.rows = 1;
39
+ box.spellcheck = false;
40
+ return box;
41
+ }
42
+ // Fit the box to its content. Height is cleared first so shrinking
43
+ // works: `scrollHeight` never reports less than the current height.
44
+ function autoGrow(box) {
45
+ box.style.height = 'auto';
46
+ box.style.height = `${box.scrollHeight}px`;
47
+ }
48
+ // A metadata value is one line by definition, so a pasted newline
49
+ // becomes a space rather than a broken header. Same length, so the
50
+ // caret doesn't move.
51
+ function sanitizeOneLine(box) {
52
+ if (box.value.includes('\n') || box.value.includes('\r')) {
53
+ box.value = box.value.replace(/[\r\n]/g, ' ');
54
+ }
55
+ }
56
+ function textInput(id) {
57
+ const input = el('input');
58
+ input.type = 'text';
59
+ input.id = id;
60
+ input.spellcheck = false;
61
+ input.autocomplete = 'off';
62
+ return input;
63
+ }
64
+ function buildDialog() {
65
+ const dialog = el('dialog', 'sm-dialog copy-dialog');
66
+ dialog.appendChild(el('h2', 'dlg-title', 'Make a local copy of this script'));
67
+ // The file-access banner, when the toggle is off: a copy that can be
68
+ // written but never read back would register as a broken script.
69
+ const banner = el('div', 'dlg-banner');
70
+ dialog.appendChild(banner);
71
+ const body = el('div', 'dlg-body');
72
+ // One tooltip per row, on the label AND the control: the label is
73
+ // what a reader points at to ask "what is this?", and the control is
74
+ // where the pointer already is when they wonder.
75
+ const row = (label, control, tooltip, note) => {
76
+ const cell = el('span', 'dlg-label', label);
77
+ // A second line under the label, for what the field accepts that
78
+ // its name doesn't say.
79
+ if (note)
80
+ cell.appendChild(el('span', 'dlg-label-note', note));
81
+ cell.title = tooltip;
82
+ control.title = tooltip;
83
+ body.appendChild(cell);
84
+ body.appendChild(control);
85
+ };
86
+ const target = el('span', 'dlg-static');
87
+ target.id = 'copy-target';
88
+ row('Target collection', target, 'Copy to this collection. It uses a directory under Chrome\u2019s Downloads directory.');
89
+ const fileName = textInput('copy-file-name');
90
+ row('Filename', fileName, 'Copy script here, relative to the collection\u2019s directory. '
91
+ + 'The relative path can include a subdirectory.', '(with subdir)');
92
+ const depsWrap = el('div', 'dlg-deps');
93
+ const depsLabel = el('label', 'dlg-check');
94
+ const deps = el('input');
95
+ deps.type = 'checkbox';
96
+ deps.id = 'copy-deps';
97
+ const depsText = el('span');
98
+ depsLabel.append(deps, depsText);
99
+ depsWrap.appendChild(depsLabel);
100
+ const depsHint = el('div', 'dlg-hint');
101
+ depsWrap.appendChild(depsHint);
102
+ const depsList = el('ul', 'dlg-deps-list');
103
+ depsWrap.appendChild(depsList);
104
+ // The row's own tooltip is about the paths it lists; the checkbox
105
+ // decides whether anything is written there, which is a different
106
+ // question, so it carries its own.
107
+ row('Dependencies', depsWrap, 'Local paths for @require and @resource files this script loads. '
108
+ + 'Files at these local paths will be used if they exist.');
109
+ depsLabel.title = 'Also save local copies of @require and @resource files if checked.';
110
+ // The header fields are a group of their own: everything above says
111
+ // where the copy goes, everything below is what will differ from the
112
+ // script it is copied from.
113
+ const heading = el('div', 'dlg-section', 'Updated headers in downloaded script');
114
+ body.appendChild(heading);
115
+ const name = el('span', 'dlg-static');
116
+ name.id = 'copy-name';
117
+ row('Name', name, 'The @name for the copied script. Unchanged, to keep a link to the source script.');
118
+ // `<original> → [new]`, so the edit is read against what it replaces
119
+ // rather than having to be remembered.
120
+ const versionWrap = el('div', 'dlg-arrow');
121
+ const versionWas = el('span', 'dlg-was');
122
+ versionWas.id = 'copy-version-was';
123
+ // Its own, overriding the row's: this half is the version being
124
+ // copied FROM, where the box beside it is the one being written.
125
+ versionWas.title = 'The @version of the source script';
126
+ const version = textInput('copy-version');
127
+ versionWrap.append(versionWas, el('span', 'dlg-arrow-mark', '\u2192'), version);
128
+ row('Version', versionWrap, 'The @version for the source and copied script. '
129
+ + 'The copied script gets a "-local" suffix by default.');
130
+ const description = growingInput('copy-description');
131
+ row('Description', description, 'The @description for the copied script');
132
+ dialog.appendChild(body);
133
+ // In the shared error row, so the text lines up with the form's
134
+ // padding rather than the dialog's edge.
135
+ const errorRow = el('div', 'dlg-error-row');
136
+ const error = el('p', 'dlg-error');
137
+ error.setAttribute('role', 'alert');
138
+ errorRow.appendChild(error);
139
+ dialog.appendChild(errorRow);
140
+ const actions = el('div', 'dlg-actions');
141
+ const cancel = el('button', undefined, 'Cancel');
142
+ cancel.type = 'button';
143
+ const ok = el('button', 'primary', 'Copy');
144
+ ok.type = 'button';
145
+ ok.id = 'copy-confirm-btn';
146
+ // Enter in the description would insert a line the header can't hold,
147
+ // and it is what a one-line field is expected to submit on. Wired
148
+ // here rather than at the field, so it can name the button directly.
149
+ description.addEventListener('keydown', (ev) => {
150
+ if (ev.key !== 'Enter')
151
+ return;
152
+ ev.preventDefault();
153
+ ok.click();
154
+ });
155
+ actions.append(cancel, ok);
156
+ dialog.appendChild(actions);
157
+ document.body.appendChild(dialog);
158
+ return {
159
+ dialog, banner, target, fileName, deps, depsLabel, depsText, depsList, depsHint,
160
+ name, versionWas, version, description, error, cancel, ok,
161
+ };
162
+ }
163
+ // Open the dialog and resolve with the new copy, or null when it was
164
+ // dismissed or nothing was written.
165
+ export function openCopyDialog(request) {
166
+ if (!elements)
167
+ elements = buildDialog();
168
+ const ui = elements;
169
+ if (dialogInFlight)
170
+ return Promise.resolve(null);
171
+ dialogInFlight = true;
172
+ return new Promise((resolve) => {
173
+ try {
174
+ runDialog(ui, request, (result) => {
175
+ dialogInFlight = false;
176
+ resolve(result);
177
+ });
178
+ }
179
+ catch (err) {
180
+ dialogInFlight = false;
181
+ resolve(null);
182
+ dlog(`[SourceMonkey] copy dialog failed to open: ${describeError(err)}`);
183
+ }
184
+ });
185
+ }
186
+ async function readOpenContext() {
187
+ const [setting, collections, fileAccess] = await Promise.all([
188
+ readDownloadsCopiesSetting(),
189
+ getCollections(),
190
+ isFileSchemeAccessAllowed(),
191
+ ]);
192
+ return {
193
+ copiesDir: setting ? downloadsCopiesDir(setting.downloadsDir) : null,
194
+ collection: findDownloadsCollection(setting, collections),
195
+ fileAccess,
196
+ };
197
+ }
198
+ function runDialog(ui, request, resolve) {
199
+ const { script } = request;
200
+ let settled = false;
201
+ // A submit is in flight. Esc is refused for its duration: closing
202
+ // would resolve the caller `null` while the files are still landing,
203
+ // so the caller would skip the exclusive-enable sweep and the
204
+ // navigation the new copy needs.
205
+ let submitting = false;
206
+ // The open-time reads have landed. Until then nothing can be checked
207
+ // against the disk, so the confirm stays off.
208
+ let context = null;
209
+ // The last COMPLETED disk check, with the file name it was made for.
210
+ // Everything about replacing — the warning line, the marks in the
211
+ // dependency list, the confirm's wording — is drawn from this and
212
+ // never from a half-known state: an edit leaves the last answer on
213
+ // screen and disables the confirm until the new one lands, so the
214
+ // marks and the button don't blink off and back on with every
215
+ // keystroke. `null` until the first check.
216
+ let checked = null;
217
+ // A check is pending or in flight, so what is on screen is the
218
+ // PREVIOUS answer. Only the confirm reacts.
219
+ let checking = false;
220
+ let checkTimer;
221
+ let checkSeq = 0;
222
+ const finish = (result) => {
223
+ if (settled)
224
+ return;
225
+ settled = true;
226
+ if (checkTimer !== undefined)
227
+ clearTimeout(checkTimer);
228
+ if (ui.dialog.open)
229
+ ui.dialog.close();
230
+ resolve(result);
231
+ };
232
+ // ----- Fill the form from the script -----
233
+ const header = script.header;
234
+ const originalVersion = header ? getValue(header, 'version') ?? '' : '';
235
+ ui.name.textContent = (header && getValue(header, 'name')) || '(no @name)';
236
+ // `<none>` rather than an empty gap: the arrow needs a left-hand
237
+ // side, and "the script declares no version" is what it says.
238
+ ui.versionWas.textContent = originalVersion || '<none>';
239
+ ui.version.value = originalVersion ? localEditVersion(originalVersion) : '';
240
+ ui.version.placeholder = originalVersion ? '' : 'none';
241
+ ui.description.value = header ? getValue(header, 'description') ?? '' : '';
242
+ ui.description.placeholder = ui.description.value ? '' : 'none';
243
+ ui.fileName.value = defaultCopyName(script);
244
+ const deps = dependencyFiles(script);
245
+ // Off to begin with; `paintDependencies` runs again when the
246
+ // open-time reads land and must not undo a tick made in between.
247
+ ui.deps.checked = false;
248
+ paintDependencies(ui, deps, null);
249
+ ui.target.replaceChildren();
250
+ ui.target.appendChild(el('span', 'dlg-target-name', DOWNLOADS_COPIES_NAME));
251
+ const targetHint = el('span', 'dlg-hint', ` (a ${DOWNLOADS_COPIES_SUBDIR} directory under Chrome's Downloads directory)`);
252
+ ui.target.appendChild(targetHint);
253
+ ui.banner.replaceChildren();
254
+ // ----- Validation -----
255
+ const currentPlan = () => ({
256
+ fileName: ui.fileName.value.trim(),
257
+ version: ui.version.value,
258
+ description: ui.description.value,
259
+ includeDependencies: ui.deps.checked && !ui.deps.disabled,
260
+ });
261
+ // What the last completed check says the copy would overwrite. The
262
+ // checkbox is read live rather than checked for: the check covers
263
+ // every file the plan COULD write, so ticking it changes what will
264
+ // be replaced without needing another listing.
265
+ const replaceState = () => {
266
+ if (!checked)
267
+ return { scriptExists: false, depsExisting: 0 };
268
+ const copyingDeps = ui.deps.checked && !ui.deps.disabled;
269
+ return {
270
+ scriptExists: checked.existing.includes(checked.fileName),
271
+ depsExisting: copyingDeps
272
+ ? checked.existing.filter((p) => p !== checked.fileName).length
273
+ : 0,
274
+ };
275
+ };
276
+ // Paint the error slot and the confirm button from the current state.
277
+ // `report` is what the last attempt said, which outranks the live
278
+ // validation until the user changes something.
279
+ let report = null;
280
+ const sync = () => {
281
+ const nameError = validateCopyName(ui.fileName.value)
282
+ ?? (context?.copiesDir && isCopyOfItself(script, context.copiesDir, ui.fileName.value)
283
+ ? COPY_OVER_ITSELF
284
+ : null);
285
+ const message = report ?? nameError;
286
+ const blocked = !context || !context.fileAccess || nameError !== null || report !== null;
287
+ const { scriptExists, depsExisting } = replaceState();
288
+ const willReplace = scriptExists || depsExisting > 0;
289
+ // Where the dependencies WOULD go, when they aren't going: still
290
+ // worth reading, but not a description of what Copy is about to
291
+ // do, so it and the list it heads are dimmed.
292
+ const copyingDeps = ui.deps.checked && !ui.deps.disabled;
293
+ ui.depsHint.classList.toggle('dimmed', !copyingDeps);
294
+ ui.depsList.classList.toggle('dimmed', !copyingDeps);
295
+ ui.error.textContent = message ?? replaceWarning(scriptExists, depsExisting);
296
+ ui.error.classList.toggle('warn', !message && willReplace);
297
+ // Paths and marks both from the last completed check, so a name
298
+ // being typed can't pair a new path with the old answer about it.
299
+ paintDependencyRows(ui, checked?.existing ?? [], checked?.fileName ?? '');
300
+ ui.fileName.classList.toggle('invalid', nameError !== null);
301
+ // A pending check is the one thing that disables the confirm
302
+ // without changing anything else on screen: the answer it would
303
+ // act on is about to be replaced, and it may well come back the
304
+ // same.
305
+ ui.ok.disabled = blocked || submitting || checking;
306
+ ui.ok.textContent = willReplace ? 'Replace' : 'Copy';
307
+ };
308
+ // The disk check: which of the files this plan would write are
309
+ // already there. Debounced behind typing, and sequenced so a slow
310
+ // listing can't overwrite the answer for a newer name.
311
+ const scheduleCheck = () => {
312
+ report = null;
313
+ checking = true;
314
+ // Bumped HERE, not only in `runCheck`: a check already awaiting a
315
+ // listing must not be allowed to land its answer for the name the
316
+ // user has just moved off. Without this it would, then clear
317
+ // `checking` and re-enable the confirm — so a click in the 250ms
318
+ // before this edit's own check ran would write the new name with
319
+ // the old name's answer about what is already there.
320
+ checkSeq++;
321
+ if (checkTimer !== undefined)
322
+ clearTimeout(checkTimer);
323
+ checkTimer = setTimeout(() => void runCheck(), 250);
324
+ sync();
325
+ };
326
+ const runCheck = async () => {
327
+ checkTimer = undefined;
328
+ const seq = ++checkSeq;
329
+ const fileName = ui.fileName.value.trim();
330
+ if (!context?.copiesDir
331
+ || validateCopyName(ui.fileName.value) !== null
332
+ || isCopyOfItself(script, context.copiesDir, ui.fileName.value)) {
333
+ // Nothing to check against — and the name is what the confirm is
334
+ // already refusing, so the previous answer stays on screen rather
335
+ // than being cleared to a state nobody can act on.
336
+ checking = false;
337
+ sync();
338
+ return;
339
+ }
340
+ // Every file the plan COULD write, dependencies included, so the
341
+ // list's marks don't depend on the checkbox.
342
+ const files = planFiles(script, { ...currentPlan(), includeDependencies: true });
343
+ try {
344
+ const found = await checkExistingFiles(context.copiesDir, files);
345
+ if (seq !== checkSeq || settled)
346
+ return;
347
+ checked = { fileName, existing: found.existing };
348
+ }
349
+ catch (err) {
350
+ if (seq !== checkSeq || settled)
351
+ return;
352
+ dlog(`[SourceMonkey] copy dialog could not check existing files: ${describeError(err)}`);
353
+ }
354
+ checking = false;
355
+ sync();
356
+ };
357
+ ui.fileName.oninput = scheduleCheck;
358
+ // No re-check: the check covers every file the plan COULD write, so
359
+ // the box only changes which of them count. `report` is cleared like
360
+ // any other edit — otherwise a failed copy's message would sit there
361
+ // blocking the confirm until some other field was touched.
362
+ ui.deps.onchange = () => { report = null; sync(); };
363
+ ui.version.oninput = () => { report = null; sync(); };
364
+ ui.description.oninput = () => {
365
+ sanitizeOneLine(ui.description);
366
+ autoGrow(ui.description);
367
+ report = null;
368
+ sync();
369
+ };
370
+ // Replaced on every open, so stale closures can't fire.
371
+ ui.cancel.onclick = () => finish(null);
372
+ ui.dialog.onclose = () => finish(null);
373
+ ui.dialog.oncancel = (event) => {
374
+ if (submitting)
375
+ event.preventDefault();
376
+ };
377
+ ui.ok.onclick = () => void submit();
378
+ const submit = async () => {
379
+ if (!context)
380
+ return;
381
+ submitting = true;
382
+ sync();
383
+ ui.cancel.disabled = true;
384
+ const spinner = el('span', 'busy-spinner');
385
+ ui.ok.after(spinner);
386
+ try {
387
+ const result = await makeLocalCopy({
388
+ script,
389
+ plan: currentPlan(),
390
+ overwrite: (() => {
391
+ const { scriptExists, depsExisting } = replaceState();
392
+ return scriptExists || depsExisting > 0;
393
+ })(),
394
+ });
395
+ finish(result);
396
+ }
397
+ catch (err) {
398
+ report = describeError(err);
399
+ }
400
+ finally {
401
+ submitting = false;
402
+ spinner.remove();
403
+ ui.cancel.disabled = false;
404
+ sync();
405
+ }
406
+ };
407
+ report = null;
408
+ checking = true;
409
+ sync();
410
+ ui.dialog.showModal();
411
+ // After `showModal`: a closed dialog is `display: none`, where a box
412
+ // has no scroll height to measure.
413
+ autoGrow(ui.description);
414
+ ui.fileName.focus();
415
+ ui.fileName.setSelectionRange(0, Math.max(0, ui.fileName.value.length - '.user.js'.length));
416
+ // ----- The open-time reads, after the dialog is on screen -----
417
+ void readOpenContext().then((ctx) => {
418
+ if (settled)
419
+ return;
420
+ context = ctx;
421
+ if (ctx.copiesDir) {
422
+ targetHint.textContent = ` (${ctx.copiesDir})`;
423
+ if (ctx.collection) {
424
+ ui.target.firstChild.textContent = effectiveDisplayName(ctx.collection);
425
+ }
426
+ }
427
+ paintDependencies(ui, deps, ctx.collection);
428
+ if (!ctx.fileAccess) {
429
+ ui.banner.replaceChildren(renderFileAccessBanner('then open this dialog again'));
430
+ }
431
+ void runCheck();
432
+ }, (err) => {
433
+ if (settled)
434
+ return;
435
+ report = `Could not read the collections: ${describeError(err)}`;
436
+ sync();
437
+ });
438
+ }
439
+ // What Replace would overwrite, as the line in the error slot. Which
440
+ // files: a dependency that is already there is marked in the list under
441
+ // the checkbox, which already names every file, so the line names
442
+ // none — and it counts dependencies only when they are being copied.
443
+ function replaceWarning(scriptExists, depsExisting) {
444
+ if (scriptExists && depsExisting > 0) {
445
+ return 'Target script file and dependency files already exist and will be replaced';
446
+ }
447
+ if (scriptExists)
448
+ return 'Target script file already exists and will be replaced';
449
+ if (depsExisting === 1)
450
+ return 'Target dependency file already exists and will be replaced';
451
+ if (depsExisting > 1)
452
+ return 'Target dependency files already exist and will be replaced';
453
+ return '';
454
+ }
455
+ function paintDependencyRows(ui, existing, fileName) {
456
+ // Dependencies go under the SCRIPT's folder, so a file name with a
457
+ // subdirectory in it moves them too — the list shows where each one
458
+ // would actually land, collection-relative, like the field above it.
459
+ const dir = fileName.includes('/') ? fileName.slice(0, fileName.lastIndexOf('/') + 1) : '';
460
+ const set = new Set(existing);
461
+ for (const li of ui.depsList.querySelectorAll('li')) {
462
+ const dep = dir + (li.dataset.dep ?? '');
463
+ li.replaceChildren(dep);
464
+ if (set.has(dep))
465
+ li.appendChild(el('span', 'dlg-exists', ' (already exists)'));
466
+ }
467
+ }
468
+ // The dependencies row: the checkbox's wording, whether it can be
469
+ // ticked at all, and the list under it.
470
+ function paintDependencies(ui, deps, collection) {
471
+ const total = deps.reduce((n, d) => n + d.size, 0);
472
+ const noun = deps.length === 1 ? 'dependency file' : 'dependency files';
473
+ const localFilesOff = collection !== null && !collectionUsesLocalFiles(collection);
474
+ if (deps.length === 0) {
475
+ ui.depsText.textContent = 'No dependency files to copy';
476
+ ui.depsHint.textContent = '';
477
+ }
478
+ else {
479
+ ui.depsText.textContent = `Also copy ${deps.length} ${noun} (${formatBytes(total)})`;
480
+ ui.depsHint.textContent = localFilesOff
481
+ ? 'Local files are turned off for this collection, so copies would not be used'
482
+ : 'Local filenames in download collection';
483
+ }
484
+ const off = deps.length === 0 || localFilesOff;
485
+ ui.deps.disabled = off;
486
+ // Only ever forced off, never on: this runs a second time when the
487
+ // open-time reads land, and a tick made before then is the user's.
488
+ if (off)
489
+ ui.deps.checked = false;
490
+ ui.depsLabel.classList.toggle('off', off);
491
+ ui.depsList.replaceChildren(...deps.map((d) => {
492
+ // Path relative to the SCRIPT's folder. `paintDependencyRows`
493
+ // renders it against the current file name and adds the
494
+ // "(already exists)" mark.
495
+ const li = el('li', undefined, d.relativePath);
496
+ // No tooltip of its own, so the row's applies: the declared URL
497
+ // this file came from says nothing about the local path the row
498
+ // is showing.
499
+ li.dataset.dep = d.relativePath;
500
+ return li;
501
+ }));
502
+ }
@@ -0,0 +1,66 @@
1
+ // Which installed copy of a script is preferred, when only one of them
2
+ // should run or be shown: the order behind "Enable all" picking one
3
+ // copy per script in a collection, and the toolbar popup picking the
4
+ // copy that stands for a script none of whose copies is active.
5
+ //
6
+ // Pure, so popup-view.ts can use it in the service worker and the
7
+ // rules can be unit tested. Design: docs/script-enablement.md.
8
+ import { compareScriptNames } from '../script-display.js';
9
+ import { compareVersions } from '../version-compare.js';
10
+ function isActive(c) {
11
+ return c.enabled && c.collectionOn;
12
+ }
13
+ // Most recently active first: an active copy before any inactive one
14
+ // (the later `activeSince` first between two active), then by when it
15
+ // went inactive. A copy with no recorded times gives no signal and
16
+ // comes last.
17
+ function compareRecency(a, b) {
18
+ const ra = isActive(a);
19
+ const rb = isActive(b);
20
+ if (ra !== rb)
21
+ return ra ? -1 : 1;
22
+ const ta = (ra ? a.activeSince : a.inactiveSince) ?? 0;
23
+ const tb = (rb ? b.activeSince : b.inactiveSince) ?? 0;
24
+ return tb - ta;
25
+ }
26
+ // The order between copies in one collection: most recently active, then
27
+ // the highest `@version` (a copy without one last), then location.
28
+ export function compareInCollection(a, b) {
29
+ const recency = compareRecency(a, b);
30
+ if (recency !== 0)
31
+ return recency;
32
+ if (a.version !== b.version) {
33
+ if (a.version === null)
34
+ return 1;
35
+ if (b.version === null)
36
+ return -1;
37
+ const v = compareVersions(b.version, a.version);
38
+ if (v !== 0)
39
+ return v;
40
+ }
41
+ return a.location < b.location ? -1 : a.location > b.location ? 1 : 0;
42
+ }
43
+ // How close a copy is to active: in an enabled collection (so only its
44
+ // own box is off), ticked in a disabled collection, unticked in one.
45
+ function closeness(c) {
46
+ if (c.collectionOn)
47
+ return 0;
48
+ return c.enabled ? 1 : 2;
49
+ }
50
+ // The order between copies anywhere: closest to active, then most
51
+ // recently active, then collection name, then `compareInCollection`.
52
+ export function comparePreferred(a, b) {
53
+ return closeness(a) - closeness(b)
54
+ || compareRecency(a, b)
55
+ || compareScriptNames(a.collectionName, b.collectionName)
56
+ || compareInCollection(a, b);
57
+ }
58
+ // The first of `copies` by `compare`, or undefined when there are none.
59
+ export function preferredCopy(copies, facts, compare = comparePreferred) {
60
+ let best;
61
+ for (const copy of copies) {
62
+ if (best === undefined || compare(facts(copy), facts(best)) < 0)
63
+ best = copy;
64
+ }
65
+ return best;
66
+ }