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:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user