This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||
export function artifactVersionLabel(
|
||||
version: { version: number; release_version?: string | null } | null | undefined
|
||||
version: { version: number; release_version?: string | null } | null | undefined,
|
||||
kind?: string
|
||||
): string {
|
||||
if (!version) return '—';
|
||||
return version.release_version
|
||||
? `${version.release_version} (upload ${version.version})`
|
||||
: `v${version.version}`;
|
||||
: kind === 'script'
|
||||
? `Unversioned (upload ${version.version})`
|
||||
: `v${version.version}`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { gameTitle } from './ui/server-labels.js';
|
||||
export interface LibraryVersion {
|
||||
id: number;
|
||||
version: number;
|
||||
release_version?: string | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** TWS source releases use three numeric components, without tags or suffixes. */
|
||||
export function scriptVersionError(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.';
|
||||
if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim()))
|
||||
return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Ignore non-script components (for example native plugins) in a TWS manifest. */
|
||||
export function parseScriptVersions(text: string): Record<string, string> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Choose a valid versions.json file.');
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data))
|
||||
throw new Error('versions.json must map script filenames to release versions.');
|
||||
const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc'));
|
||||
if (!entries.length) throw new Error('versions.json contains no .gsc filenames.');
|
||||
for (const [name, value] of entries) {
|
||||
if (name.includes('/') || name.includes('\\'))
|
||||
throw new Error(`Use an exact filename in versions.json: ${name}`);
|
||||
const error = scriptVersionError(value);
|
||||
if (error) throw new Error(`${name}: ${error}`);
|
||||
}
|
||||
return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()]));
|
||||
}
|
||||
|
||||
/** Uploaded filenames must never resolve inherited object properties. */
|
||||
export function manifestVersion(versions: Record<string, string>, filename: string): string {
|
||||
return Object.hasOwn(versions, filename) ? versions[filename] : '';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scriptVersionError } from '../../script-version.js';
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
@@ -73,6 +74,7 @@ export class ScriptError extends Error {
|
||||
| 'baseline-exists'
|
||||
| 'empty-overlay'
|
||||
| 'invalid-release-version'
|
||||
| 'release-version-conflict'
|
||||
| 'too-large'
|
||||
) {
|
||||
super(message);
|
||||
@@ -95,6 +97,14 @@ function normalizeReleaseVersion(value: string | null | undefined): string | nul
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
function releaseForKind(kind: ArtifactKind, value: string | null | undefined): string | null {
|
||||
if (kind === 'script') {
|
||||
const error = scriptVersionError(value);
|
||||
if (error) throw new ScriptError(error, 'invalid-release-version');
|
||||
}
|
||||
return normalizeReleaseVersion(value);
|
||||
}
|
||||
|
||||
export function getScript(id: number): ScriptRow | undefined {
|
||||
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
|
||||
}
|
||||
@@ -273,7 +283,7 @@ export function createScript(input: CreateScriptInput): {
|
||||
throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
||||
|
||||
const sha = sha256(input.content);
|
||||
const releaseVersion = normalizeReleaseVersion(input.releaseVersion);
|
||||
const releaseVersion = releaseForKind(kind, input.releaseVersion);
|
||||
const size = input.content.byteLength;
|
||||
|
||||
const insertScript = db().prepare(
|
||||
@@ -335,27 +345,39 @@ export function uploadNewVersion(
|
||||
}
|
||||
}
|
||||
|
||||
const latest = getLatestVersion(scriptId);
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
const release = releaseForKind(script.kind, releaseVersion);
|
||||
const sha = sha256(content);
|
||||
const release = normalizeReleaseVersion(releaseVersion);
|
||||
return db().transaction(() => {
|
||||
if (script.kind === 'script') {
|
||||
const existing = listVersions(scriptId).filter((v) => v.release_version === release);
|
||||
if (existing.some((v) => v.sha256 !== sha)) {
|
||||
throw new ScriptError(
|
||||
`Release ${release} already has different contents. Bump the script release version.`,
|
||||
'release-version-conflict'
|
||||
);
|
||||
}
|
||||
if (existing.length) return existing[0];
|
||||
}
|
||||
const latest = getLatestVersion(scriptId);
|
||||
const nextVersion = (latest?.version ?? 0) + 1;
|
||||
|
||||
const res = db()
|
||||
.prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
||||
const res = db()
|
||||
.prepare(
|
||||
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
scriptId,
|
||||
nextVersion,
|
||||
sha,
|
||||
content.byteLength,
|
||||
putFileBlob(content),
|
||||
notes ?? null,
|
||||
script.kind,
|
||||
release
|
||||
);
|
||||
return getVersion(Number(res.lastInsertRowid))!;
|
||||
)
|
||||
.run(
|
||||
scriptId,
|
||||
nextVersion,
|
||||
sha,
|
||||
content.byteLength,
|
||||
putFileBlob(content),
|
||||
notes ?? null,
|
||||
script.kind,
|
||||
release
|
||||
);
|
||||
return getVersion(Number(res.lastInsertRowid))!;
|
||||
})();
|
||||
}
|
||||
|
||||
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -69,7 +71,13 @@
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let installedVersions = $state<
|
||||
Array<{ server: string; version: number; versionId: number; recordedAt: string }>
|
||||
Array<{
|
||||
server: string;
|
||||
version: number;
|
||||
versionId: number;
|
||||
release_version: string | null;
|
||||
recordedAt: string;
|
||||
}>
|
||||
>([]);
|
||||
interface ScopeImpact {
|
||||
before: string[];
|
||||
@@ -103,6 +111,7 @@
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let releaseVersion = $state('');
|
||||
let versionManifest = $state<Record<string, string>>({});
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
@@ -244,6 +253,7 @@
|
||||
function openUploadModal(): void {
|
||||
if (!editable || runningBetaId !== null) return;
|
||||
releaseVersion = '';
|
||||
versionManifest = {};
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
flash = null;
|
||||
@@ -271,16 +281,23 @@
|
||||
function pickVersionFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
versionFile = t.files?.[0] ?? null;
|
||||
if (!releaseVersion.trim() && versionFile)
|
||||
releaseVersion = manifestVersion(versionManifest, versionFile.name);
|
||||
}
|
||||
|
||||
async function uploadVersion(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!editable || !versionFile || uploadingVersion) return;
|
||||
if (family === 'script' && scriptVersionError(releaseVersion)) {
|
||||
flash = scriptVersionError(releaseVersion);
|
||||
return;
|
||||
}
|
||||
uploadingVersion = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('content', versionFile);
|
||||
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
|
||||
if (family === 'plugin' || family === 'script')
|
||||
form.append('releaseVersion', releaseVersion.trim());
|
||||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
@@ -419,7 +436,7 @@
|
||||
{#if latestVersion}<Card padding="lg"
|
||||
><div class="release-summary">
|
||||
<div>
|
||||
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
|
||||
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
|
||||
<StatusBadge
|
||||
state={latestVersion.id === a.current_version_id
|
||||
? 'promoted'
|
||||
@@ -517,7 +534,7 @@
|
||||
<span class="k">Promoted version</span>
|
||||
<span class="v mono">
|
||||
{#if currentVersion}
|
||||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
|
||||
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
|
||||
0,
|
||||
8
|
||||
)}
|
||||
@@ -541,7 +558,9 @@
|
||||
<a href={'/servers/' + encodeURIComponent(installed.server)}
|
||||
>{serverTitle(installed.server)}</a
|
||||
>
|
||||
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
|
||||
· {artifactVersionLabel(installed, family)} · <RelativeTime
|
||||
value={installed.recordedAt}
|
||||
/>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{:else if family !== 'config'}<p class="dim">
|
||||
@@ -575,7 +594,7 @@
|
||||
<div class="version-list">
|
||||
{#each versions.slice(0, limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-num">{artifactVersionLabel(v, family)}</span>
|
||||
<span class="v-notes"
|
||||
>{v.notes ?? ''}
|
||||
{#if v.source}<a
|
||||
@@ -750,14 +769,28 @@
|
||||
disabled={uploadingVersion}
|
||||
/>
|
||||
</Field>
|
||||
{#if family === 'plugin'}
|
||||
{#if family === 'script'}
|
||||
<ScriptVersionManifest
|
||||
disabled={uploadingVersion}
|
||||
onload={(versions) => {
|
||||
versionManifest = versions;
|
||||
if (versionFile && !releaseVersion.trim())
|
||||
releaseVersion = manifestVersion(versions, versionFile.name);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if family === 'plugin' || family === 'script'}
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
|
||||
hint={family === 'script'
|
||||
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
|
||||
: 'Optional. Use the publisher’s version or release tag, such as v3.1.0.'}
|
||||
>
|
||||
<Input
|
||||
bind:value={releaseVersion}
|
||||
placeholder="v3.1.0"
|
||||
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
|
||||
required={family === 'script'}
|
||||
maxlength={80}
|
||||
disabled={uploadingVersion}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import Field from './primitives/Field.svelte';
|
||||
import Input from './primitives/Input.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import DiffViewer from './DiffViewer.svelte';
|
||||
@@ -31,19 +35,38 @@
|
||||
} = $props();
|
||||
interface Replacement {
|
||||
file: File;
|
||||
releaseVersion: string;
|
||||
sha: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
}
|
||||
let manifest = $state<Record<string, string>>({});
|
||||
let replacements = $state<Record<number, Replacement>>({});
|
||||
let notes = $state(''),
|
||||
error = $state(''),
|
||||
reviewing = $state(false),
|
||||
busy = $state(false);
|
||||
const versionsValid = $derived(
|
||||
files.every(
|
||||
(entry) =>
|
||||
!replacements[entry.id] ||
|
||||
entry.kind !== 'script' ||
|
||||
!scriptVersionError(replacements[entry.id].releaseVersion)
|
||||
)
|
||||
);
|
||||
function applyManifest(versions: Record<string, string>) {
|
||||
manifest = versions;
|
||||
for (const entry of files) {
|
||||
const replacement = replacements[entry.id];
|
||||
if (entry.kind === 'script' && replacement && !replacement.releaseVersion.trim())
|
||||
replacement.releaseVersion = manifestVersion(manifest, replacement.file.name);
|
||||
}
|
||||
}
|
||||
const count = $derived(Object.keys(replacements).length);
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
replacements = {};
|
||||
manifest = {};
|
||||
notes = '';
|
||||
error = '';
|
||||
reviewing = false;
|
||||
@@ -90,7 +113,13 @@
|
||||
after = new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
replacements[entry.id] = { file, sha, before, after };
|
||||
replacements[entry.id] = {
|
||||
file,
|
||||
sha,
|
||||
before,
|
||||
after,
|
||||
releaseVersion: entry.kind === 'script' ? manifestVersion(manifest, file.name) : ''
|
||||
};
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
} finally {
|
||||
@@ -98,14 +127,17 @@
|
||||
}
|
||||
}
|
||||
async function deploy() {
|
||||
if (!versionsValid) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('expectedRevision', String(revision));
|
||||
form.set('notes', notes);
|
||||
for (const [scriptId, replacement] of Object.entries(replacements))
|
||||
for (const [scriptId, replacement] of Object.entries(replacements)) {
|
||||
form.set(`file:${scriptId}`, replacement.file);
|
||||
form.set(`release:${scriptId}`, replacement.releaseVersion.trim());
|
||||
}
|
||||
const response = await fetch(`/api/betas/${betaId}/revisions`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
@@ -132,12 +164,15 @@
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if !reviewing}
|
||||
<p>Choose replacements for the files you changed.</p>
|
||||
{#if files.some((entry) => entry.kind === 'script')}
|
||||
<ScriptVersionManifest disabled={busy} onload={applyManifest} />
|
||||
{/if}
|
||||
<div class="files">
|
||||
{#each files as entry}
|
||||
<label
|
||||
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
|
||||
>{entry.target_rel_path}</code
|
||||
>
|
||||
><strong
|
||||
>{entry.name} · {artifactVersionLabel(entry.version, entry.kind)}</strong
|
||||
><code>{entry.target_rel_path}</code>
|
||||
<input
|
||||
type="file"
|
||||
disabled={busy}
|
||||
@@ -145,6 +180,26 @@
|
||||
/>
|
||||
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
||||
</label>
|
||||
{#if replacements[entry.id] && (entry.kind === 'script' || entry.kind === 'plugin')}
|
||||
<Field
|
||||
label={`${entry.name} release version`}
|
||||
hint={entry.kind === 'script'
|
||||
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
|
||||
: 'Optional publisher release tag.'}
|
||||
>
|
||||
<Input
|
||||
bind:value={replacements[entry.id].releaseVersion}
|
||||
required={entry.kind === 'script'}
|
||||
maxlength={80}
|
||||
disabled={busy}
|
||||
/>
|
||||
</Field>
|
||||
{#if entry.kind === 'script' && scriptVersionError(replacements[entry.id].releaseVersion)}
|
||||
<p class="ui-error">
|
||||
{scriptVersionError(replacements[entry.id].releaseVersion)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<label class="notes"
|
||||
@@ -177,6 +232,9 @@
|
||||
{@const replacement = replacements[entry.id]}
|
||||
<section>
|
||||
<h3>{entry.name}</h3>
|
||||
{#if replacement.releaseVersion}<p>
|
||||
Release {entry.version?.release_version ?? 'Unversioned'} → {replacement.releaseVersion.trim()}
|
||||
</p>{/if}
|
||||
<p>
|
||||
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
|
||||
bytes)
|
||||
@@ -204,7 +262,7 @@
|
||||
{:else}<Button
|
||||
variant="primary"
|
||||
onclick={() => (reviewing = true)}
|
||||
disabled={busy || !count}>Review update</Button
|
||||
disabled={busy || !count || !versionsValid}>Review update</Button
|
||||
>{/if}
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
@@ -10,15 +10,21 @@
|
||||
let {
|
||||
current,
|
||||
latest,
|
||||
kind,
|
||||
betaId = null
|
||||
}: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
|
||||
}: {
|
||||
current: Version | null;
|
||||
latest: Version | null;
|
||||
betaId?: number | null;
|
||||
kind?: string;
|
||||
} = $props();
|
||||
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
|
||||
const ambiguous = $derived(
|
||||
!!newer?.release_version && newer.release_version === current?.release_version
|
||||
);
|
||||
function label(version: Version) {
|
||||
return ambiguous
|
||||
? artifactVersionLabel(version)
|
||||
return kind === 'script' || ambiguous
|
||||
? artifactVersionLabel(version, kind)
|
||||
: version.release_version || `v${version.version}`;
|
||||
}
|
||||
const state = $derived(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
@@ -20,23 +23,34 @@
|
||||
scriptId: number;
|
||||
versionId: number;
|
||||
version: number;
|
||||
release_version: string | null;
|
||||
}
|
||||
interface Upload {
|
||||
key: number;
|
||||
file: File;
|
||||
target: number | 'new' | '';
|
||||
name: string;
|
||||
releaseVersion: string;
|
||||
duplicates: Duplicate[];
|
||||
inspecting: boolean;
|
||||
inspected: boolean;
|
||||
error: string;
|
||||
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
||||
result:
|
||||
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
|
||||
}
|
||||
let dialog: HTMLDialogElement;
|
||||
let input: HTMLInputElement;
|
||||
let queue = $state<Upload[]>([]);
|
||||
let busy = $state(false);
|
||||
let notes = $state('');
|
||||
let manifest = $state<Record<string, string>>({});
|
||||
function applyManifest(versions: Record<string, string>) {
|
||||
manifest = versions;
|
||||
for (const item of queue) {
|
||||
if (!item.result && !item.releaseVersion.trim())
|
||||
item.releaseVersion = manifestVersion(manifest, item.file.name);
|
||||
}
|
||||
}
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
@@ -80,6 +94,7 @@
|
||||
// only when there is no pending file to lose.
|
||||
if (!pending.length) {
|
||||
queue = [];
|
||||
manifest = {};
|
||||
notes = '';
|
||||
message = '';
|
||||
preferredTarget = scriptId ?? null;
|
||||
@@ -121,6 +136,7 @@
|
||||
preferredTarget ??
|
||||
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''),
|
||||
releaseVersion: manifestVersion(manifest, file.name),
|
||||
duplicates: [],
|
||||
inspecting: true,
|
||||
inspected: false,
|
||||
@@ -153,9 +169,13 @@
|
||||
}
|
||||
}
|
||||
function duplicate(item: Upload) {
|
||||
return item.duplicates.find((d) => d.scriptId === item.target);
|
||||
return item.duplicates.find(
|
||||
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
|
||||
);
|
||||
}
|
||||
function validation(item: Upload): string {
|
||||
const versionError = scriptVersionError(item.releaseVersion);
|
||||
if (versionError) return versionError;
|
||||
if (item.target === '')
|
||||
return 'Several scripts use this filename. Choose which script to update.';
|
||||
if (item.target === 'new') {
|
||||
@@ -194,6 +214,7 @@
|
||||
const form = new FormData();
|
||||
form.append('content', item.file);
|
||||
form.append('notes', notes.trim());
|
||||
form.append('releaseVersion', item.releaseVersion.trim());
|
||||
const creating = item.target === 'new';
|
||||
if (creating) {
|
||||
form.append('name', item.name.trim());
|
||||
@@ -211,6 +232,7 @@
|
||||
scriptId: creating ? body.script.id : (item.target as number),
|
||||
versionId: creating ? body.versionId : body.version.id,
|
||||
version: creating ? 1 : body.version.version,
|
||||
release_version: item.releaseVersion.trim(),
|
||||
reused: !!body.reused
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -286,6 +308,7 @@
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
|
||||
<div class="queue" aria-live="polite">
|
||||
{#each queue as item (item.key)}
|
||||
<section class="upload-item" aria-label={item.file.name}>
|
||||
@@ -304,11 +327,26 @@
|
||||
</div>
|
||||
{#if item.result}
|
||||
<p class="success">
|
||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
|
||||
.version} ·
|
||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
|
||||
item.result,
|
||||
'script'
|
||||
)} ·
|
||||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||||
</p>
|
||||
{:else}
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
|
||||
>
|
||||
<Input
|
||||
bind:value={item.releaseVersion}
|
||||
required
|
||||
maxlength={80}
|
||||
placeholder="1.2.0"
|
||||
disabled={busy}
|
||||
oninput={() => (item.error = '')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Destination">
|
||||
<Select
|
||||
bind:value={item.target}
|
||||
@@ -336,8 +374,10 @@
|
||||
</p>{/if}
|
||||
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||
{:else if duplicate(item)}<p class="success">
|
||||
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
|
||||
version.
|
||||
Already uploaded as {artifactVersionLabel(
|
||||
duplicate(item),
|
||||
'script'
|
||||
)}. We’ll reuse that version.
|
||||
</p>
|
||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
|
||||
New version for {scripts.find((s) => s.id === item.target)?.name}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { parseScriptVersions } from '$lib/script-version.js';
|
||||
import Field from './primitives/Field.svelte';
|
||||
import Input from './primitives/Input.svelte';
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
onload
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onload: (versions: Record<string, string>) => void;
|
||||
} = $props();
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
async function choose(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
loading = true;
|
||||
message = error = '';
|
||||
try {
|
||||
if (file.size > 1024 * 1024)
|
||||
throw new Error('versions.json must be smaller than 1 MiB.');
|
||||
const versions = parseScriptVersions(await file.text());
|
||||
onload(versions);
|
||||
message = `Loaded ${Object.keys(versions).length} script versions. Review the matched values below.`;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field
|
||||
label="Version manifest (optional)"
|
||||
hint="Choose versions.json from the same source checkout as your files. Matches exact filenames; fills empty release version fields."
|
||||
>
|
||||
<Input type="file" accept=".json" disabled={disabled || loading} onchange={choose} />
|
||||
</Field>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
Reference in New Issue
Block a user