update Markdown skills
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
|
||||
ChevronDown, ChevronLeft, Download, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles,
|
||||
Trash2, Upload,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
@@ -10,8 +11,12 @@ import {
|
||||
} from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import {
|
||||
PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet, validateSkillSource,
|
||||
} from '@/lib/skills/registry';
|
||||
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
import { normalizeUpload } from '@/lib/skills/skillFields';
|
||||
import { countSections } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
|
||||
@@ -100,7 +105,7 @@ function contribution(skill, facet) {
|
||||
}
|
||||
|
||||
/** One skill, as a row in the management list. */
|
||||
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
|
||||
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport }) {
|
||||
const pages = surfaceLabels(skill);
|
||||
const { type, detail } = contribution(skill, facet);
|
||||
/* A definition doing both jobs appears on both lists. Saying so on the row is
|
||||
@@ -161,6 +166,14 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
|
||||
</DropdownMenuItem>
|
||||
{/* The definition, back out as the file it is. An account skill
|
||||
lives in this browser and nowhere else; without a way to take
|
||||
one out, "stored locally" means "stored until something goes
|
||||
wrong". */}
|
||||
<DropdownMenuItem onClick={() => onExport(skill)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export .md
|
||||
</DropdownMenuItem>
|
||||
{/* Offered for every definition. What it does differs — an account
|
||||
definition is deleted, a shipped one is taken out of this
|
||||
workspace — and the dialog says which. */}
|
||||
@@ -194,6 +207,7 @@ export default function WorkspaceSkills() {
|
||||
const [page, setPage] = useState('all');
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
const [showRemoved, setShowRemoved] = useState(false);
|
||||
const importRef = React.useRef(null);
|
||||
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
/**
|
||||
@@ -296,8 +310,7 @@ export default function WorkspaceSkills() {
|
||||
patch.customSkills = upsertCustomSkill(customSkills, source).next;
|
||||
}
|
||||
|
||||
updatePreferences.mutate(patch);
|
||||
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
|
||||
updatePreferences.mutate(patch, reportSave(enabled ? 'Skill enabled' : 'Skill disabled'));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -319,9 +332,8 @@ export default function WorkspaceSkills() {
|
||||
disabledSkills: [...new Set([...disabledSkills, skill.id])],
|
||||
};
|
||||
|
||||
updatePreferences.mutate(patch);
|
||||
updatePreferences.mutate(patch, reportSave(`${skill.name} removed`));
|
||||
setPendingDelete(null);
|
||||
toast.success(`${skill.name} removed`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -337,10 +349,86 @@ export default function WorkspaceSkills() {
|
||||
* list with its switch off, and one click is the whole cost.
|
||||
*/
|
||||
const restoreSkill = (skill) => {
|
||||
updatePreferences.mutate({
|
||||
removedSkills: removedSkills.filter((id) => id !== skill.id),
|
||||
updatePreferences.mutate(
|
||||
{ removedSkills: removedSkills.filter((id) => id !== skill.id) },
|
||||
reportSave(`${skill.name} restored — switched off`)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A definition, downloaded as the file it already is.
|
||||
*
|
||||
* `skill.markdown` is the artefact — the same text a file in `src/skills/`
|
||||
* holds — so this is a copy, not an export format. That is what makes it
|
||||
* useful: the file that comes out can be uploaded into another browser,
|
||||
* committed to the repository, or read by a person.
|
||||
*/
|
||||
const exportSkill = (skill) => {
|
||||
const blob = new Blob([skill.markdown], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${skill.id}.md`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/** Every definition this account authored, one file at a time. */
|
||||
const exportAll = () => {
|
||||
const mine = skills.filter((s) => s.custom);
|
||||
if (!mine.length) {
|
||||
toast.error('This workspace has no account-authored skills to export.');
|
||||
return;
|
||||
}
|
||||
mine.forEach(exportSkill);
|
||||
toast.success(`Exported ${mine.length} skill${mine.length === 1 ? '' : 's'}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Definitions read back in from files.
|
||||
*
|
||||
* The same validator and the same writer the editors use, so an imported file
|
||||
* cannot enter the registry by a route that checks less than authoring does.
|
||||
* Each file is reported on its own: one bad file in a selection of six must
|
||||
* not cost the other five.
|
||||
*/
|
||||
const importFiles = (event) => {
|
||||
const files = [...(event.target.files || [])];
|
||||
event.target.value = '';
|
||||
if (!files.length) return;
|
||||
|
||||
Promise.all(files.map((file) => file.text().then(
|
||||
(raw) => ({ name: file.name, raw }),
|
||||
() => ({ name: file.name, raw: null })
|
||||
))).then((read) => {
|
||||
let next = customSkills;
|
||||
const added = [];
|
||||
const refused = [];
|
||||
|
||||
for (const { name, raw: text } of read) {
|
||||
/* Same normalisation the editors apply, so a file imported here and the
|
||||
same file uploaded there become the identical stored definition. */
|
||||
const raw = text === null ? null : normalizeUpload(text);
|
||||
const problem = raw === null ? 'could not be read' : validateSkillSource(raw);
|
||||
if (problem) {
|
||||
refused.push(`${name}: ${problem}`);
|
||||
continue;
|
||||
}
|
||||
const result = upsertCustomSkill(next, raw);
|
||||
next = result.next;
|
||||
added.push(result.skill.name);
|
||||
}
|
||||
|
||||
if (added.length) {
|
||||
updatePreferences.mutate(
|
||||
{ customSkills: next },
|
||||
reportSave(`Imported ${added.length} skill${added.length === 1 ? '' : 's'}`)
|
||||
);
|
||||
}
|
||||
refused.forEach((message) => toast.error(message));
|
||||
});
|
||||
toast.success(`${skill.name} restored — switched off`);
|
||||
};
|
||||
|
||||
const handleFixDiagnostic = (d) => {
|
||||
@@ -356,15 +444,16 @@ export default function WorkspaceSkills() {
|
||||
|
||||
if (isCustom) {
|
||||
const nextCustom = removeCustomSkill(customSkills, d.skillId);
|
||||
updatePreferences.mutate({ customSkills: nextCustom });
|
||||
toast.success(`Removed custom skill "${d.skillId}"`);
|
||||
updatePreferences.mutate(
|
||||
{ customSkills: nextCustom },
|
||||
reportSave(`Removed custom skill "${d.skillId}"`)
|
||||
);
|
||||
} else {
|
||||
const patch = {
|
||||
removedSkills: [...new Set([...removedSkills, d.skillId])],
|
||||
disabledSkills: [...new Set([...disabledSkills, d.skillId])],
|
||||
};
|
||||
updatePreferences.mutate(patch);
|
||||
toast.success(`Removed "${d.skillId}" from workspace`);
|
||||
updatePreferences.mutate(patch, reportSave(`Removed "${d.skillId}" from workspace`));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,12 +469,10 @@ export default function WorkspaceSkills() {
|
||||
nextDisabled = [...new Set([...nextDisabled, d.skillId])];
|
||||
});
|
||||
|
||||
updatePreferences.mutate({
|
||||
customSkills: nextCustom,
|
||||
removedSkills: nextRemoved,
|
||||
disabledSkills: nextDisabled,
|
||||
});
|
||||
toast.success('Resolved all skill problems');
|
||||
updatePreferences.mutate(
|
||||
{ customSkills: nextCustom, removedSkills: nextRemoved, disabledSkills: nextDisabled },
|
||||
reportSave('Resolved all skill problems')
|
||||
);
|
||||
};
|
||||
|
||||
/** A row opens the editor its own kind is authored in. */
|
||||
@@ -419,6 +506,24 @@ export default function WorkspaceSkills() {
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
</Button>
|
||||
{/* Account definitions live in this browser's storage and nowhere
|
||||
else. Import and export are what make that a place rather than a
|
||||
trap: a skill can be moved to another machine, handed to someone,
|
||||
or committed to `src/skills/`. */}
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
multiple
|
||||
onChange={importFiles}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => importRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Import .md
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={exportAll}>
|
||||
<Download aria-hidden="true" /> Export all
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
|
||||
<Plus aria-hidden="true" /> {current.add}
|
||||
</Button>
|
||||
@@ -533,6 +638,7 @@ export default function WorkspaceSkills() {
|
||||
onToggle={toggleSkill}
|
||||
onEdit={(s) => navigate(editRoute(s))}
|
||||
onDelete={setPendingDelete}
|
||||
onExport={exportSkill}
|
||||
/>
|
||||
))}
|
||||
</Surface>
|
||||
|
||||
Reference in New Issue
Block a user