feat(manager): per-row community upload + copy + update diag

Pulls publish + copy helpers out of ExportTab into shared single-row-publish util. New CommunityRowActions component renders Upload + Copy icon buttons; wired into all six library list views (Wildcards, FixedValues, Combines, Derivations, Constraints, Bundles) so users no longer need the Import/Export tab for single-row publish. Also adds diagnostic console.info in communityUpdateStore.check showing module/bundle/candidate/update counts per check — helps debug 'no update pill' reports without UI changes.
This commit is contained in:
DumiFlex
2026-06-05 08:10:11 +03:00
parent 6f7e1c1081
commit 34d58e65d9
10 changed files with 272 additions and 63 deletions
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* Two-button row action group: Upload-to-community + Copy-payload.
*
* Drops into every list view's #actions slot + every editor's footer
* toolbar so the community publish path isn't hidden inside the
* Import/Export tab. Same engine-row shape as the Export tab's
* "Publish to community" button — both code paths go through
* `single-row-publish` helpers.
*
* Renders as bare icon buttons styled to match the existing
* actions-column convention (the trash icon at the row's right
* edge). Sized small so they don't dominate the row.
*/
import type { BundleRow, ModuleRow } from "../api/types";
import Button from "./ui/Button.vue";
import { useToast } from "../composables/useToast";
import {
buildBundlePublishable,
buildModulePublishable,
copyPayloadToClipboard,
publishToCommunity,
} from "../import-export/single-row-publish";
interface Props {
/** Library row to act on. Module rows carry `type`; bundle rows carry `children`. */
row: ModuleRow | BundleRow;
/** Discriminator for the shape — module vs bundle. */
kind: "module" | "bundle";
/**
* When true, renders Upload+Copy as full pill buttons (label+icon).
* Default is icon-only, which is what the per-row actions column
* wants. Editor footers pass `true` so the action is more
* discoverable next to the Delete pill.
*/
labeled?: boolean;
}
const props = defineProps<Props>();
const toast = useToast();
function publishablePayload() {
if (props.kind === "bundle") {
return buildBundlePublishable(props.row as BundleRow);
}
return buildModulePublishable(props.row as ModuleRow);
}
function onPublish() {
publishToCommunity(publishablePayload());
}
async function onCopy() {
const ok = await copyPayloadToClipboard(publishablePayload());
if (ok) {
toast.push({
severity: "success",
summary: "Payload copied",
detail: "Engine-row JSON is on your clipboard.",
life: 2500,
});
} else {
toast.push({
severity: "error",
summary: "Copy failed",
detail: "Clipboard access was denied by the browser.",
life: 3500,
});
}
}
</script>
<template>
<template v-if="labeled">
<Button
variant="secondary"
size="sm"
icon="pi-share-alt"
:aria-label="`Publish ${row.name} to community`"
:title="`Publish ${row.name} to community`"
@click="onPublish"
>Publish</Button>
<Button
variant="ghost"
size="sm"
icon="pi-copy"
:aria-label="`Copy ${row.name} payload`"
:title="`Copy ${row.name} payload to clipboard`"
@click="onCopy"
>Copy</Button>
</template>
<template v-else>
<Button
variant="ghost"
size="sm"
icon="pi-share-alt"
:aria-label="`Publish ${row.name} to community`"
:title="`Publish ${row.name} to community`"
@click="onPublish"
/>
<Button
variant="ghost"
size="sm"
icon="pi-copy"
:aria-label="`Copy ${row.name} payload`"
:title="`Copy ${row.name} payload to clipboard`"
@click="onCopy"
/>
</template>
</template>
+13 -63
View File
@@ -26,7 +26,12 @@ import ExportDepWarningModal from "./ExportDepWarningModal.vue";
import type { ExportDepWarningRow } from "./ExportDepWarningModal.vue";
import { liveLibraryToRawPayload } from "./live-library-adapter";
import { buildDepGraph, transitiveClosure } from "./dep-graph";
import { WPC_API_URL } from "../config/links";
import {
buildBundlePublishable,
buildModulePublishable,
publishToCommunity as publishToCommunityShared,
type PublishablePayload,
} from "./single-row-publish";
const toast = useToast();
@@ -538,20 +543,15 @@ async function onDepWarningExportAnyway(): Promise<void> {
// else — single-row publish is the only flow the community currently
// supports (each post is one entity).
interface SingleSelected {
kind: "module" | "bundle";
payload: Record<string, unknown>;
name: string;
description: string;
}
/**
* Detect whether the current selection is a single publishable entity.
* Single publishable = exactly one module OR exactly one bundle, and
* every other bucket empty (including categories + templates, which
* the community can't host yet).
* the community can't host yet). Returns the engine-row payload via
* the shared `single-row-publish` helpers so per-row buttons on
* ModuleListView / editors emit byte-for-byte the same shape.
*/
const singleSelected = computed<SingleSelected | null>(() => {
const singleSelected = computed<PublishablePayload | null>(() => {
let total = 0;
let kind: "module" | "bundle" | null = null;
let id: string | null = null;
@@ -566,64 +566,14 @@ const singleSelected = computed<SingleSelected | null>(() => {
if (total !== 1 || !kind || !id) return null;
if (kind === "bundle") {
const row = bundles.value.find((b) => b.id === id);
if (!row) return null;
// Engine-row bundle shape (no `type`, no nested `payload`; `children`
// carry the module rows verbatim). Strip server-side bookkeeping
// (payload_hash, version, timestamps) — community assigns its own.
const payload: Record<string, unknown> = {
id: row.id,
name: row.name,
description: row.description,
color: row.color,
category_id: row.category_id,
tags: row.tags,
is_favorite: row.is_favorite,
children: row.children,
};
return { kind: "bundle", payload, name: row.name, description: row.description };
return row ? buildBundlePublishable(row) : null;
}
const mod = modules.value.find((m) => m.id === id);
if (!mod) return null;
// Engine-row module shape — keep `type` + nested `payload` so the
// community validator's `derive_kind_from_payload` reads it correctly.
const payload: Record<string, unknown> = {
id: mod.id,
type: mod.type,
name: mod.name,
description: mod.description,
category_id: mod.category_id,
tags: mod.tags,
is_favorite: mod.is_favorite,
payload: mod.payload,
};
return { kind: "module", payload, name: mod.name, description: mod.description };
return mod ? buildModulePublishable(mod) : null;
});
/**
* utf8-safe base64 (mirror of `b64ToText` on the community web side).
* btoa() only handles latin-1, so we run the JSON through TextEncoder
* first to handle non-ASCII names / descriptions cleanly.
*/
function textToB64(text: string): string {
const bytes = new TextEncoder().encode(text);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function publishToCommunity() {
const sel = singleSelected.value;
if (!sel) return;
const b64 = textToB64(JSON.stringify(sel.payload));
const url = new URL(`${WPC_API_URL}/upload`);
url.searchParams.set("from", "spa");
// Hash, not query — see comment above on size + transmission concerns.
const hash = new URLSearchParams();
hash.set("payload", b64);
if (sel.name) hash.set("name", sel.name);
if (sel.description) hash.set("description", sel.description);
const full = `${url.toString()}#${hash.toString()}`;
window.open(full, "_blank", "noopener");
if (singleSelected.value) publishToCommunityShared(singleSelected.value);
}
function clearAll() {
@@ -0,0 +1,119 @@
/**
* Single-row community publish + copy helpers.
*
* Pulled out of ExportTab.vue so per-row action buttons (ModuleListView,
* each editor's footer toolbar) can reuse the exact same engine-row
* shape + deeplink format without duplicating logic. The Export tab
* still owns the multi-row export pipeline; this is just the
* single-entity path that community publishing needs.
*
* Engine-row shape contract (matches the canonical engine output —
* see `engine/db/repositories.py:_row_to_module` /
* `_row_to_bundle`): module rows carry top-level `id`/`type`/`name`
* with type-specific content nested under `payload`. Bundles drop
* `type` + replace `payload` with `children`, an array of full
* module-row snapshots. Server-stamped fields (`payload_hash`,
* `version`, timestamps, `snapshot_fingerprint`) are stripped — the
* receiving end (community web, or the user's own clipboard) assigns
* its own.
*/
import type { BundleRow, ModuleRow } from "../api/types";
import { WPC_API_URL } from "../config/links";
export interface PublishablePayload {
/** Engine-row shape ready to ship: `{id, type?, name, payload|children, …}` */
payload: Record<string, unknown>;
/** Display name (used for the URL hash + clipboard toast). */
name: string;
/** Optional human description (prefilled on the community publish form). */
description: string;
}
/**
* Build the engine-row payload for a module library row. Strips
* server-stamped lifecycle fields — community + the user's clipboard
* both want a fresh row that they can re-stamp.
*/
export function buildModulePublishable(row: ModuleRow): PublishablePayload {
const payload: Record<string, unknown> = {
id: row.id,
type: row.type,
name: row.name,
description: row.description,
category_id: row.category_id,
tags: row.tags,
is_favorite: row.is_favorite,
payload: row.payload,
};
return { payload, name: row.name, description: row.description };
}
/**
* Build the engine-row payload for a bundle library row. Children
* keep their full module-row shape (deep snapshots) — the community
* importer expects to recurse on those at install time.
*/
export function buildBundlePublishable(row: BundleRow): PublishablePayload {
const payload: Record<string, unknown> = {
id: row.id,
name: row.name,
description: row.description,
color: row.color,
category_id: row.category_id,
tags: row.tags,
is_favorite: row.is_favorite,
children: row.children,
};
return { payload, name: row.name, description: row.description };
}
/**
* utf8-safe base64 — `btoa()` only handles latin-1, so we walk the
* UTF-8 bytes manually. Mirror of `b64ToText` on the community web
* side (`web/src/views/PublishView.vue:b64ToText`).
*/
function textToB64(text: string): string {
const bytes = new TextEncoder().encode(text);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
/**
* Open the community web's /upload page with the payload pre-filled
* via the URL hash. Hash (not query) keeps the payload off the
* request log and sidesteps Edge's ~2KB address-bar limit on bundle
* exports.
*
* Window.open uses `noopener` so the new tab can't reach back into
* our document via `window.opener`.
*/
export function publishToCommunity(pub: PublishablePayload): void {
const b64 = textToB64(JSON.stringify(pub.payload));
const url = new URL(`${WPC_API_URL}/upload`);
url.searchParams.set("from", "spa");
const hash = new URLSearchParams();
hash.set("payload", b64);
if (pub.name) hash.set("name", pub.name);
if (pub.description) hash.set("description", pub.description);
const full = `${url.toString()}#${hash.toString()}`;
window.open(full, "_blank", "noopener");
}
/**
* Copy the pretty-printed engine-row JSON to the clipboard. Resolves
* to true on success, false on failure (clipboard denied / no API).
* Callers should surface a toast for either case.
*/
export async function copyPayloadToClipboard(
pub: PublishablePayload,
): Promise<boolean> {
if (typeof navigator === "undefined" || !navigator.clipboard) return false;
try {
await navigator.clipboard.writeText(JSON.stringify(pub.payload, null, 2));
return true;
} catch {
return false;
}
}
@@ -107,6 +107,19 @@ export const useCommunityUpdateStore = defineStore("communityUpdates", () => {
}
}
// Diagnostic: when no rows carry community origin (the typical
// failure mode is "nothing was installed since migration 013
// ran" or "the install bridge dropped origin"), surface that
// explicitly so a user wondering why no update pills show up
// can confirm the candidate set in DevTools instead of poking
// around the UI. Only logs ONCE per check.
// eslint-disable-next-line no-console
console.info(
"[wpc-update-check] modules=%d bundles=%d candidates=%d",
input.modules.length,
input.bundles.length,
candidates.length,
);
if (candidates.length === 0) {
updatesByEntityId.value = new Map();
return;
@@ -169,6 +182,12 @@ export const useCommunityUpdateStore = defineStore("communityUpdates", () => {
}
updatesByEntityId.value = next;
lastCheckedAt.value = Date.now();
// eslint-disable-next-line no-console
console.info(
"[wpc-update-check] fetched=%d updates=%d",
latestBySlug.size,
next.size,
);
} catch {
// Defensive: any unexpected error path clears stale entries so
// we don't render a misleading "v2 available" pill against a
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeBundleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import ValidityIcon from "../components/ValidityIcon.vue";
import Button from "../components/ui/Button.vue";
import Select from "../components/ui/Select.vue";
@@ -355,6 +356,7 @@ function frameColor(row: BundleRow): string {
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<CommunityRowActions :row="row" kind="bundle" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import ValidityIcon from "../components/ValidityIcon.vue";
import { validateModule } from "../utils/validateModule";
import Button from "../components/ui/Button.vue";
@@ -306,6 +307,7 @@ function templateParts(row: ModuleRow): TemplatePart[] {
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
<CommunityRowActions :row="row" kind="module" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import ValidityIcon from "../components/ValidityIcon.vue";
import Button from "../components/ui/Button.vue";
import Select from "../components/ui/Select.vue";
@@ -345,6 +346,7 @@ function formatFactor(f: number): string {
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
<CommunityRowActions :row="row" kind="module" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import ValidityIcon from "../components/ValidityIcon.vue";
import { validateModule } from "../utils/validateModule";
import Button from "../components/ui/Button.vue";
@@ -303,6 +304,7 @@ function actView(a: DerivationAction | undefined): { verb: string; target: strin
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
<CommunityRowActions :row="row" kind="module" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import ValidityIcon from "../components/ValidityIcon.vue";
import { validateModule } from "../utils/validateModule";
import Button from "../components/ui/Button.vue";
@@ -279,6 +280,7 @@ function topValues(row: ModuleRow): NamedValue[] { return values(row).slice(0, 4
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
<CommunityRowActions :row="row" kind="module" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>
+2
View File
@@ -7,6 +7,7 @@ import { useLoadError } from "../composables/useLoadError";
import { useBulkActions } from "../composables/useBulkActions";
import { makeModuleStoreAdapter } from "../composables/bulkAdapters";
import ModuleListView from "../components/ModuleListView.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
import Button from "../components/ui/Button.vue";
import Select from "../components/ui/Select.vue";
@@ -469,6 +470,7 @@ function issuesFor(row: ModuleRow) {
<template #actions="{ row }">
<Button variant="ghost" size="sm" icon="pi-pencil" aria-label="Edit" @click="edit(row)" />
<Button variant="ghost" size="sm" icon="pi-clone" aria-label="Duplicate" @click="dup(row)" />
<CommunityRowActions :row="row" kind="module" />
<Button variant="ghost" size="sm" icon="pi-trash" aria-label="Delete" @click="del(row)" />
</template>