feat(spa): import/export with per-item selection + conflict resolution

Two-pane Export tab with collapsible groups, search, presets (Full/Wildcards/Favorites),
and live bundle summary. Import tab with drag-drop, conflict badges (new/exists/modified),
per-item selection, and rename/overwrite/skip resolution.

Adds bundleSelection.ts pure helpers (filtering, presets, hashing, classification)
backed by 21 unit tests, plus 9 ImportExport.vue mount tests covering the export
group select-all flow and the import bundle parse + filter + send pipeline.
This commit is contained in:
DumiFlex
2026-04-27 06:45:44 +03:00
parent 8e28b40655
commit a5dfe52511
6 changed files with 1928 additions and 44 deletions
+272
View File
@@ -0,0 +1,272 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../api/client", () => ({
api: {
modules: {
list: vi.fn(),
},
categories: {
list: vi.fn(),
},
exportBundle: vi.fn(),
importBundle: vi.fn(),
},
ApiError: class ApiError extends Error {},
}));
import { api } from "../api/client";
import ImportExport from "../views/ImportExport.vue";
import type { CategoryRow, ModuleRow } from "../api/types";
const apiAny = api as unknown as {
modules: { list: ReturnType<typeof vi.fn> };
categories: { list: ReturnType<typeof vi.fn> };
importBundle: ReturnType<typeof vi.fn>;
exportBundle: ReturnType<typeof vi.fn>;
};
function mkModule(over: Partial<ModuleRow>): ModuleRow {
return {
id: "wc_aaaa1111",
type: "wildcard",
name: "Sample",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: { options: [] },
version: 1,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...over,
};
}
function mkCategory(over: Partial<CategoryRow>): CategoryRow {
return { id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0, ...over };
}
beforeEach(() => {
setActivePinia(createPinia());
apiAny.modules.list.mockReset();
apiAny.categories.list.mockReset();
apiAny.importBundle.mockReset();
});
afterEach(() => vi.clearAllMocks());
function mountView() {
return mount(ImportExport, {
global: { plugins: [PrimeVue, ToastService] },
});
}
describe("ImportExport.vue — Export tab", () => {
it("renders both tabs and starts on Export", async () => {
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
expect(wrap.find('[data-test="io-tab-export"]').exists()).toBe(true);
expect(wrap.find('[data-test="io-tab-import"]').exists()).toBe(true);
expect(wrap.find('[data-test="io-export-pane"]').exists()).toBe(true);
});
it("disables 'Download bundle' when nothing is selected", async () => {
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
const btn = wrap.find('[data-test="io-export-download"]');
expect(btn.exists()).toBe(true);
expect(btn.attributes("disabled")).toBeDefined();
});
it("seeds the default selection to 'Full library' once data loads", async () => {
apiAny.modules.list.mockResolvedValue({
items: [
mkModule({ id: "wc_a", type: "wildcard", name: "Pose" }),
mkModule({ id: "fv_a", type: "fixed_values", name: "Defaults" }),
],
total: 2,
});
apiAny.categories.list.mockResolvedValue({
items: [mkCategory({ id: "cat_a" })],
});
const wrap = mountView();
await flushPromises();
// Download button enabled because everything is checked by default.
const btn = wrap.find('[data-test="io-export-download"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("'None' button clears all selections and disables download", async () => {
apiAny.modules.list.mockResolvedValue({
items: [mkModule({ id: "wc_a", type: "wildcard" })],
total: 1,
});
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
await flushPromises();
const btn = wrap.find('[data-test="io-export-download"]');
expect(btn.attributes("disabled")).toBeDefined();
});
it("'Wildcards only' preset selects only wildcard rows", async () => {
apiAny.modules.list.mockResolvedValue({
items: [
mkModule({ id: "wc_a", type: "wildcard" }),
mkModule({ id: "fv_a", type: "fixed_values" }),
],
total: 2,
});
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
await wrap.find('[data-test="io-preset-wildcards"]').trigger("click");
await flushPromises();
const summary = wrap.find('[data-test="io-export-summary"]').text();
// 1 wildcard, 0 fixed values, 1 total
expect(summary).toMatch(/Wildcards\s*1/);
expect(summary).toMatch(/Fixed values\s*0/);
});
});
describe("ImportExport.vue — Import tab", () => {
/**
* jsdom doesn't expose `DragEvent` and `HTMLInputElement.files` is
* read-only — both are facts of life for unit tests. We invoke the file
* input's `change` handler with a manually-attached `files` array, which
* routes through the same `handleFile()` path the dropzone uses.
*/
async function feedBundle(wrap: ReturnType<typeof mountView>, bundle: unknown) {
const file = new File([JSON.stringify(bundle)], "bundle.json", {
type: "application/json",
});
const input = wrap.find<HTMLInputElement>('[data-test="io-file-input"]');
Object.defineProperty(input.element, "files", {
value: [file], configurable: true,
});
await input.trigger("change");
await flushPromises();
}
it("switches to Import tab and shows the dropzone", async () => {
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
await wrap.find('[data-test="io-tab-import"]').trigger("click");
await flushPromises();
expect(wrap.find('[data-test="io-import-pane"]').exists()).toBe(true);
expect(wrap.find('[data-test="io-dropzone"]').exists()).toBe(true);
});
it("renders conflict badges for parsed bundle rows", async () => {
// Local library has wc_a; incoming bundle re-imports wc_a (modified) plus a new one.
apiAny.modules.list.mockResolvedValue({
items: [mkModule({ id: "wc_a", payload: { options: [{ id: "o1", value: "x", weight: 1 }] } })],
total: 1,
});
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
await wrap.find('[data-test="io-tab-import"]').trigger("click");
await flushPromises();
await feedBundle(wrap, {
version: 1,
modules: [
mkModule({ id: "wc_a", payload: { options: [{ id: "o1", value: "y", weight: 1 }] } }),
mkModule({ id: "wc_b", name: "Brand New" }),
],
categories: [],
});
expect(wrap.find('[data-test="io-import-badge-wc_a"]').exists()).toBe(true);
expect(wrap.find('[data-test="io-import-badge-wc_b"]').exists()).toBe(true);
expect(wrap.find('[data-test="io-import-badge-wc_a"]').text()).toBe("modified");
expect(wrap.find('[data-test="io-import-badge-wc_b"]').text()).toBe("new");
});
it("ticking a group's select-all box ticks all leaf checkboxes", async () => {
apiAny.modules.list.mockResolvedValue({
items: [
mkModule({ id: "wc_a", type: "wildcard", name: "A" }),
mkModule({ id: "wc_b", type: "wildcard", name: "B" }),
],
total: 2,
});
apiAny.categories.list.mockResolvedValue({ items: [] });
const wrap = mountView();
await flushPromises();
// Clear, then select all wildcards via the group checkbox.
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
await flushPromises();
// Find the group's checkbox (PrimeVue Checkbox uses an inner <input>).
const groupCheckEl = wrap.get('[data-test="io-export-group-check-wildcard"]');
const input = groupCheckEl.find("input");
await input.setValue(true);
await flushPromises();
// Both rows should now be selected → download should be enabled.
const btn = wrap.find('[data-test="io-export-download"]');
expect(btn.attributes("disabled")).toBeUndefined();
const summary = wrap.find('[data-test="io-export-summary"]').text();
expect(summary).toMatch(/Wildcards\s*2/);
});
it("import button sends only the checked items to api.importBundle", async () => {
apiAny.modules.list.mockResolvedValue({ items: [], total: 0 });
apiAny.categories.list.mockResolvedValue({ items: [] });
apiAny.importBundle.mockResolvedValue({
modules_imported: 1, categories_imported: 0, skipped: [],
});
const wrap = mountView();
await flushPromises();
await wrap.find('[data-test="io-tab-import"]').trigger("click");
await flushPromises();
await feedBundle(wrap, {
version: 1,
modules: [
mkModule({ id: "wc_keep", name: "Keep me" }),
mkModule({ id: "wc_drop", name: "Drop me" }),
],
categories: [],
});
// Untick wc_drop by clicking its row (toggle off — was selected by default).
const dropRow = wrap.find('[data-test="io-import-row-wc_drop"]');
expect(dropRow.exists()).toBe(true);
await dropRow.trigger("click");
await flushPromises();
// Hit the import button.
const submit = wrap.find('[data-test="io-import-submit"]');
expect(submit.exists()).toBe(true);
await submit.trigger("click");
await flushPromises();
expect(apiAny.importBundle).toHaveBeenCalledTimes(1);
const sent = apiAny.importBundle.mock.calls[0]![0];
expect(sent.version).toBe(1);
expect(sent.modules.map((m: ModuleRow) => m.id)).toEqual(["wc_keep"]);
});
});
@@ -0,0 +1,234 @@
import { describe, expect, it } from "vitest";
import {
buildFilteredBundle,
bundleSizeBytes,
canonicalJson,
classifyCategory,
classifyModule,
formatBytes,
GROUPS,
hashPayload,
presetFavoritesOnly,
presetFull,
presetWildcardsOnly,
selectionCounts,
selectionKey,
} from "../utils/bundleSelection";
import type { CategoryRow, ModuleRow } from "../api/types";
function mkModule(over: Partial<ModuleRow>): ModuleRow {
return {
id: "wc_aaaa1111",
type: "wildcard",
name: "Sample",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {},
version: 1,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...over,
};
}
function mkCategory(over: Partial<CategoryRow>): CategoryRow {
return {
id: "cat_a",
name: "Style",
color: "#a78bfa",
icon: null,
sort_order: 0,
...over,
};
}
describe("bundleSelection — selection key", () => {
it("formats `<group>:<id>`", () => {
expect(selectionKey("wildcard", "wc_x")).toBe("wildcard:wc_x");
expect(selectionKey("category", "cat_a")).toBe("category:cat_a");
});
});
describe("bundleSelection — GROUPS", () => {
it("includes a category pseudo-group with type=null", () => {
const cat = GROUPS.find((g) => g.key === "category");
expect(cat).toBeDefined();
expect(cat?.type).toBeNull();
});
});
describe("bundleSelection — buildFilteredBundle", () => {
const modules = [
mkModule({ id: "wc_a", type: "wildcard", category_id: "cat_a" }),
mkModule({ id: "fv_b", type: "fixed_values", category_id: "cat_b" }),
mkModule({ id: "cb_c", type: "combine", category_id: null }),
];
const categories = [
mkCategory({ id: "cat_a", name: "Style" }),
mkCategory({ id: "cat_b", name: "Lighting" }),
mkCategory({ id: "cat_z", name: "Unused" }),
];
it("filters modules by selection set", () => {
const sel = new Set<string>([
selectionKey("wildcard", "wc_a"),
selectionKey("combine", "cb_c"),
]);
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
expect(bundle.modules.map((m) => m.id).sort()).toEqual(["cb_c", "wc_a"]);
});
it("auto-includes referenced categories even when not explicitly checked", () => {
const sel = new Set<string>([selectionKey("wildcard", "wc_a")]);
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
expect(bundle.categories.map((c) => c.id)).toEqual(["cat_a"]);
});
it("respects explicit category checks even with no module references", () => {
const sel = new Set<string>([selectionKey("category", "cat_z")]);
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
expect(bundle.categories.map((c) => c.id)).toEqual(["cat_z"]);
expect(bundle.modules).toHaveLength(0);
});
it("merges referenced + explicitly checked categories without duplicates", () => {
const sel = new Set<string>([
selectionKey("wildcard", "wc_a"), // refs cat_a
selectionKey("category", "cat_a"), // explicit
selectionKey("category", "cat_z"), // explicit
]);
const bundle = buildFilteredBundle({ modules, categories, selected: sel });
expect(bundle.categories.map((c) => c.id).sort()).toEqual(["cat_a", "cat_z"]);
});
it("sets bundle version=1 and includes exported_at timestamp", () => {
const bundle = buildFilteredBundle({
modules: [], categories: [], selected: new Set(),
});
expect(bundle.version).toBe(1);
expect(typeof bundle.exported_at).toBe("string");
});
});
describe("bundleSelection — bundleSizeBytes", () => {
it("matches JSON.stringify(...).length", () => {
const bundle = buildFilteredBundle({
modules: [mkModule({ id: "wc_a" })],
categories: [mkCategory({ id: "cat_a" })],
selected: new Set([
selectionKey("wildcard", "wc_a"),
selectionKey("category", "cat_a"),
]),
});
expect(bundleSizeBytes(bundle)).toBe(JSON.stringify(bundle).length);
});
it("formatBytes produces human-readable strings", () => {
expect(formatBytes(123)).toBe("123 B");
expect(formatBytes(2048)).toBe("2.0 KB");
expect(formatBytes(1024 * 1024 * 3)).toBe("3.00 MB");
});
});
describe("bundleSelection — presets", () => {
const modules = [
mkModule({ id: "wc_a", type: "wildcard", is_favorite: true }),
mkModule({ id: "wc_b", type: "wildcard", is_favorite: false }),
mkModule({ id: "fv_a", type: "fixed_values", is_favorite: true }),
mkModule({ id: "cb_a", type: "combine", is_favorite: false }),
];
const categories = [mkCategory({ id: "cat_a" })];
it("Full library — selects every module + every category", () => {
const sel = presetFull(modules, categories);
expect(sel.size).toBe(modules.length + categories.length);
expect(sel.has("wildcard:wc_a")).toBe(true);
expect(sel.has("category:cat_a")).toBe(true);
});
it("Wildcards only — selects exactly wildcards (no fixed_values/combines)", () => {
const sel = presetWildcardsOnly(modules);
expect(Array.from(sel).sort()).toEqual(["wildcard:wc_a", "wildcard:wc_b"]);
});
it("Favorites only — selects every favorited module across kinds", () => {
const sel = presetFavoritesOnly(modules);
expect(Array.from(sel).sort()).toEqual([
"fixed_values:fv_a", "wildcard:wc_a",
]);
});
});
describe("bundleSelection — selectionCounts", () => {
it("counts per group + total", () => {
const sel = new Set([
"wildcard:wc_a", "wildcard:wc_b",
"fixed_values:fv_a",
"category:cat_a",
]);
const c = selectionCounts(sel);
expect(c.wildcard).toBe(2);
expect(c.fixed_values).toBe(1);
expect(c.combine).toBe(0);
expect(c.category).toBe(1);
expect(c.total).toBe(4);
});
});
describe("bundleSelection — canonicalJson + hashPayload", () => {
it("canonicalJson is order-insensitive", () => {
const a = canonicalJson({ b: 1, a: 2 });
const b = canonicalJson({ a: 2, b: 1 });
expect(a).toBe(b);
});
it("hashPayload differs when payloads differ", () => {
expect(hashPayload({ a: 1 })).not.toBe(hashPayload({ a: 2 }));
});
it("hashPayload is stable across key reorderings", () => {
expect(hashPayload({ a: 1, b: 2 })).toBe(hashPayload({ b: 2, a: 1 }));
});
});
describe("bundleSelection — classifyModule", () => {
it("returns `new` when id is unknown locally", () => {
const local = new Map<string, ModuleRow>();
const incoming = mkModule({ id: "wc_new", payload: { x: 1 } });
expect(classifyModule(incoming, local).kind).toBe("new");
});
it("returns `exists` when payload is identical", () => {
const existing = mkModule({ id: "wc_a", payload: { foo: 1 } });
const local = new Map<string, ModuleRow>([[existing.id, existing]]);
const incoming = mkModule({ id: "wc_a", payload: { foo: 1 } });
const r = classifyModule(incoming, local);
expect(r.kind).toBe("exists");
expect(r.existingId).toBe("wc_a");
});
it("returns `modified` when id matches but payload diverges", () => {
const existing = mkModule({ id: "wc_a", payload: { foo: 1 } });
const local = new Map<string, ModuleRow>([[existing.id, existing]]);
const incoming = mkModule({ id: "wc_a", payload: { foo: 2 } });
const r = classifyModule(incoming, local);
expect(r.kind).toBe("modified");
expect(r.existingId).toBe("wc_a");
});
});
describe("bundleSelection — classifyCategory", () => {
it("returns `new` when id is unknown locally", () => {
const r = classifyCategory(mkCategory({ id: "cat_z" }), new Map());
expect(r.kind).toBe("new");
});
it("returns `modified` when name or color differs", () => {
const existing = mkCategory({ id: "cat_a", color: "#aaaaaa" });
const local = new Map([[existing.id, existing]]);
const r = classifyCategory(mkCategory({ id: "cat_a", color: "#bbbbbb" }), local);
expect(r.kind).toBe("modified");
});
});
+7 -1
View File
@@ -118,7 +118,13 @@ export const api = {
exportBundle() {
return request<ImportBundle>("/wp/api/export", { method: "GET" });
},
importBundle(bundle: ImportBundle) {
/**
* POST a bundle to /wp/api/import. The backend processes only what's in
* the payload, so a "partial" bundle (subset of modules / categories) is a
* valid input — the import-export UI relies on this to ship per-item
* selections without inventing a new endpoint.
*/
importBundle(bundle: ImportBundle | Partial<ImportBundle> & { version: 1 }) {
return request<ImportResult>("/wp/api/import", {
method: "POST", body: JSON.stringify(bundle),
});
+245
View File
@@ -0,0 +1,245 @@
/**
* Pure helpers for the Import / Export selection UI.
*
* All functions here are deterministic and free of DOM / network dependencies
* so they can be unit-tested under vanilla Vitest. The view layer composes
* them on top of reactive selection state.
*/
import type {
CategoryRow,
ImportBundle,
ModuleRow,
ModuleType,
} from "../api/types";
/** Selection group keys ↔ module types (categories are a pseudo-group). */
export type GroupKey =
| "wildcard"
| "fixed_values"
| "combine"
| "derivation"
| "constraint"
| "pipeline"
| "category";
export interface GroupMeta {
key: GroupKey;
label: string;
icon: string;
color: string;
/** Module type this group represents — `null` for the categories group. */
type: ModuleType | null;
}
/**
* Display order mirrors the prototype's MODULE_GROUPS in
* docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx.
*/
export const GROUPS: GroupMeta[] = [
{ key: "wildcard", label: "Wildcards", icon: "pi pi-th-large", color: "var(--wp-kind-wildcard)", type: "wildcard" },
{ key: "fixed_values", label: "Fixed Values", icon: "pi pi-tag", color: "var(--wp-kind-fixed)", type: "fixed_values" },
{ key: "combine", label: "Combines", icon: "pi pi-share-alt", color: "var(--wp-kind-combine)", type: "combine" },
{ key: "derivation", label: "Derivations", icon: "pi pi-code", color: "var(--wp-kind-derivation)", type: "derivation" },
{ key: "constraint", label: "Constraints", icon: "pi pi-sitemap", color: "var(--wp-kind-constraint)", type: "constraint" },
{ key: "pipeline", label: "Pipelines", icon: "pi pi-list", color: "var(--wp-kind-pipeline)", type: "pipeline" },
{ key: "category", label: "Categories", icon: "pi pi-bookmark", color: "var(--wp-kind-category, #60a5fa)", type: null },
];
/** Stable selection key — `<group>:<id>`. */
export function selectionKey(group: GroupKey, id: string): string {
return `${group}:${id}`;
}
/** Map a module's type to its selection group. */
export function groupForModule(type: ModuleType): GroupKey {
return type;
}
/**
* Stable canonical JSON for a payload — sorts keys recursively. Used to
* compute a quick payload hash so import can flag rows where the id matches
* but the payload diverged (the "modified" badge).
*/
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === "object") {
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
sorted[k] = sortKeys((value as Record<string, unknown>)[k]);
}
return sorted;
}
return value;
}
/**
* Cheap, deterministic 32-bit FNV-1a hash of an arbitrary string. Returned
* as 8 hex chars. Used purely for change-detection — never security.
*/
export function quickHash(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
/** Hash of a module's payload — stable across reorders. */
export function hashPayload(payload: Record<string, unknown> | undefined): string {
return quickHash(canonicalJson(payload ?? {}));
}
export interface FilteredBundleInput {
modules: ModuleRow[];
categories: CategoryRow[];
/** Selection key set — see `selectionKey()`. */
selected: Set<string>;
}
/**
* Build a partial ImportBundle from the source library + a selection set.
*
* Modules are filtered by their per-row checkbox. Categories are kept if:
* - they are explicitly checked in the categories group, OR
* - they are referenced by any selected module (`category_id` match).
*
* The second rule guarantees the imported bundle is self-consistent: every
* module that mentions a category brings that category along, even when the
* user only ticked module rows.
*/
export function buildFilteredBundle(input: FilteredBundleInput): ImportBundle {
const { modules, categories, selected } = input;
const pickedModules = modules.filter((m) =>
selected.has(selectionKey(groupForModule(m.type), m.id)),
);
const referenced = new Set<string>();
for (const m of pickedModules) {
if (m.category_id) referenced.add(m.category_id);
}
const pickedCategories = categories.filter(
(c) => selected.has(selectionKey("category", c.id)) || referenced.has(c.id),
);
return {
version: 1,
exported_at: new Date().toISOString(),
modules: pickedModules,
categories: pickedCategories,
};
}
/** Approximate bundle size in bytes — `JSON.stringify(bundle).length`. */
export function bundleSizeBytes(bundle: ImportBundle): number {
return JSON.stringify(bundle).length;
}
/** Human-friendly bytes — "1.2 KB", "340 B", "2.4 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
}
/** Per-group counts of selected items, plus total. */
export function selectionCounts(
selected: Set<string>,
): Record<GroupKey, number> & { total: number } {
const counts: Record<GroupKey, number> & { total: number } = {
wildcard: 0, fixed_values: 0, combine: 0,
derivation: 0, constraint: 0, pipeline: 0,
category: 0, total: 0,
};
for (const key of selected) {
const idx = key.indexOf(":");
if (idx <= 0) continue;
const g = key.slice(0, idx) as GroupKey;
if (g in counts) {
counts[g] += 1;
counts.total += 1;
}
}
return counts;
}
// ---------- Presets ----------
/**
* "Full library" — every module + every category.
*/
export function presetFull(
modules: ModuleRow[], categories: CategoryRow[],
): Set<string> {
const s = new Set<string>();
for (const m of modules) s.add(selectionKey(groupForModule(m.type), m.id));
for (const c of categories) s.add(selectionKey("category", c.id));
return s;
}
/** "Wildcards only" — every wildcard module, nothing else. */
export function presetWildcardsOnly(modules: ModuleRow[]): Set<string> {
const s = new Set<string>();
for (const m of modules) {
if (m.type === "wildcard") s.add(selectionKey("wildcard", m.id));
}
return s;
}
/** "Favorites only" — every favorited module across all kinds. */
export function presetFavoritesOnly(modules: ModuleRow[]): Set<string> {
const s = new Set<string>();
for (const m of modules) {
if (m.is_favorite) s.add(selectionKey(groupForModule(m.type), m.id));
}
return s;
}
// ---------- Import-side conflict detection ----------
export type ConflictKind = "new" | "exists" | "modified";
export interface ConflictResult {
kind: ConflictKind;
/** Existing local module id, when the incoming row clashes with one. */
existingId?: string;
}
/**
* Classify an incoming module row against the current local library.
*
* - `new` — id not present locally
* - `exists` — id matches and payload looks identical
* - `modified` — id matches but payload diverged (different shape)
*/
export function classifyModule(
incoming: ModuleRow,
localById: Map<string, ModuleRow>,
): ConflictResult {
const local = localById.get(incoming.id);
if (!local) return { kind: "new" };
const a = hashPayload(local.payload);
const b = hashPayload(incoming.payload);
if (a === b) return { kind: "exists", existingId: local.id };
return { kind: "modified", existingId: local.id };
}
export function classifyCategory(
incoming: CategoryRow,
localById: Map<string, CategoryRow>,
): ConflictResult {
const local = localById.get(incoming.id);
if (!local) return { kind: "new" };
if (local.name === incoming.name && local.color === incoming.color) {
return { kind: "exists", existingId: local.id };
}
return { kind: "modified", existingId: local.id };
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-BLJAO7d9.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-B7C8brQ3.css">
<script type="module" crossorigin src="/wp/assets/manager-Z-SdbdKR.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CMgfxADo.css">
</head>
<body>
<div id="app"></div>