///
/**
* Folksonomy Tags - Community-Enabled Tag List Sub-Pattern
*
* A folksonomy-enabled tag list that learns from collective usage.
* Like Flickr tags (public, emergent ontology) rather than Gmail labels (private silos).
*
* FEATURES:
* - Local-first autocomplete: Shows tags from user's space matching the same scope
* - Community fallback: When no local matches, show dimmed suggestions from community
* - Preferential attachment: Popular community tags sort first (most used -> least used)
* - Telemetry: Posts add/use/remove events to community aggregator charm
*
* USAGE:
* ```tsx
* const tags = Writable.of([]);
*
* ```
*
* AGGREGATOR DISCOVERY:
* This pattern auto-discovers the aggregator using wish("#folksonomy-aggregator").
* Deploy and favorite the folksonomy-aggregator charm for community features.
* Without the aggregator, falls back to local-only mode.
*
* NOTE: Due to a runtime bug where CustomEvent details aren't passed through
* ct-render boundaries, we use $value binding instead of onct-select handlers.
*/
import {
type Default,
derive,
handler,
lift,
NAME,
pattern,
type Stream,
UI,
type VNode,
wish,
Writable,
} from "commontools";
/**
* Tag event sent to the aggregator.
*/
interface TagEvent {
scope: string;
tag: string;
action: "add" | "use" | "remove";
timestamp: number;
}
/**
* Community tag suggestion from the aggregator.
*/
interface CommunityTagSuggestion {
tag: string;
count: number;
}
/**
* Aggregator interface for type-safe wish().
* Must match the Output interface of folksonomy-aggregator.tsx.
*/
interface AggregatorCharm {
events: TagEvent[];
postEvent: Stream;
suggestions: Record;
}
interface FolksonomyTagsInput {
/** Namespace key (e.g., GitHub URL of the pattern using it) */
scope: Writable>;
/** User's tags for this scope - bidirectional binding */
tags: Writable>;
/** Optional: Direct reference to aggregator (bypasses wish() discovery) */
aggregator?: AggregatorCharm;
}
interface FolksonomyTagsOutput {
[NAME]: string;
[UI]: VNode;
tags: string[];
addTag: Stream<{ tag: string }>;
removeTag: Stream<{ tag: string }>;
}
/**
* Autocomplete item structure for ct-autocomplete.
*/
interface AutocompleteItem {
value: string;
label: string;
group: string;
searchAliases?: string[];
data?: { count?: number };
}
// Handler for programmatic tag addition
const addTagHandler = handler<{ tag: string }, { tags: Writable }>(
(event, { tags }) => {
const tag = event.tag?.trim();
if (!tag) return;
const currentTags = tags.get() || [];
if (
currentTags.some((t: string) => t.toLowerCase() === tag.toLowerCase())
) {
return;
}
tags.set([...currentTags, tag]);
},
);
// Handler for programmatic tag removal
const removeTagHandler = handler<{ tag: string }, { tags: Writable }>(
(event, { tags }) => {
const tag = event.tag?.trim();
if (!tag) return;
const currentTags = tags.get() || [];
tags.set(currentTags.filter((t: string) => t !== tag));
},
);
// Handler to post telemetry event to aggregator
const _postTelemetryEvent = handler<
{ tag: string; action: "add" | "use" | "remove" },
{ aggregatorStream: Stream | null; scope: string }
>((event, { aggregatorStream, scope }) => {
if (!aggregatorStream || !scope || !event.tag) return;
const tagEvent: TagEvent = {
scope,
tag: event.tag,
action: event.action,
timestamp: Date.now(),
};
// Stream from derive() is a Cell containing the stream - call .send() directly
try {
aggregatorStream.send(tagEvent);
} catch (e) {
console.warn("[folksonomy-tags] Failed to post telemetry:", e);
}
});
// Handler for clicking the remove button on a tag
// Posts "remove" event to aggregator
const onRemoveTag = handler<
unknown,
{
tags: Writable;
index: number;
aggregatorStream: Stream | null;
scope: string;
}
>((_, { tags, index, aggregatorStream, scope }) => {
const currentTags = tags.get() || [];
const removedTag = currentTags[index];
tags.set(currentTags.toSpliced(index, 1));
// Post remove event to aggregator
if (removedTag && aggregatorStream && scope) {
try {
aggregatorStream.send({
scope,
tag: removedTag,
action: "remove",
timestamp: Date.now(),
});
} catch (e) {
console.warn("[folksonomy-tags] Failed to post remove event:", e);
}
}
});
// Handler for detecting tag additions via change event
// Compares previous tags with current to find what was added
const onTagsChanged = handler<
{ value: string | string[]; oldValue: string | string[] },
{
tags: Writable;
previousTags: Writable;
aggregatorStream: Stream | null;
scope: string;
}
>((_, { tags, previousTags, aggregatorStream, scope }) => {
const current = tags.get() || [];
const previous = previousTags.get() || [];
let added: string[];
if (previous.length === 0 && current.length > 1) {
// First change with pre-existing tags loaded from storage.
// previousTags starts empty, so a naive diff would emit telemetry for ALL tags.
// Since autocomplete adds one tag at a time, the newest tag is the last one.
// Only emit telemetry for that one to avoid inflating counts.
added = [current[current.length - 1]];
} else {
// Normal case: diff to find newly added tags
added = current.filter((t) => !previous.includes(t));
}
// Post add events for each new tag
if (added.length > 0 && aggregatorStream && scope) {
try {
for (const tag of added) {
aggregatorStream.send({
scope,
tag,
action: "add",
timestamp: Date.now(),
});
}
} catch (e) {
console.warn("[folksonomy-tags] Failed to post add events:", e);
}
}
// Update previous tags to current
previousTags.set([...current]);
});
// Lift function to check if we have tags
const hasTags = lift((tags: string[]) => tags && tags.length > 0);
// Lift function to build autocomplete items
const buildAutocompleteItems = lift(
({
localTags,
communitySuggestions,
currentTags,
}: {
localTags: string[];
communitySuggestions: CommunityTagSuggestion[];
currentTags: string[];
}): AutocompleteItem[] => {
const items: AutocompleteItem[] = [];
const currentTagsLower = new Set(
(currentTags || []).map((t) => t.toLowerCase()),
);
// Add local tags first (from user's space with same scope)
for (const tag of localTags || []) {
if (!currentTagsLower.has(tag.toLowerCase())) {
items.push({
value: tag,
label: tag,
group: "Your tags",
});
}
}
// Add community suggestions (not already in local or current)
const localTagsLower = new Set(
(localTags || []).map((t) => t.toLowerCase()),
);
for (const suggestion of communitySuggestions || []) {
if (
!currentTagsLower.has(suggestion.tag.toLowerCase()) &&
!localTagsLower.has(suggestion.tag.toLowerCase())
) {
items.push({
value: suggestion.tag,
label: `${suggestion.tag} (${suggestion.count})`,
group: "Community",
data: { count: suggestion.count },
});
}
}
return items;
},
);
export const FolksonomyTags = pattern<
FolksonomyTagsInput,
FolksonomyTagsOutput
>(
({ scope, tags, aggregator: injectedAggregator }) => {
// Use injected aggregator if provided, otherwise discover via wish()
// Search both favorites (~) and current space mentionables (.)
const aggregatorWish = wish({
query: "#folksonomy-aggregator",
scope: ["~", "."],
});
// Use injected aggregator if available, otherwise use wish result
const aggregator = derive(
[injectedAggregator, aggregatorWish.result],
(
[injected, wished]: [
AggregatorCharm | undefined,
AggregatorCharm | null,
],
) => injected ?? wished ?? null,
);
// Get the aggregator's postEvent stream for telemetry
const aggregatorStream = derive(
aggregator,
(agg: AggregatorCharm | null) =>
(agg?.postEvent as Stream) ?? null,
);
// Track previous tags for change detection
const previousTags = Writable.of([]).for("previousTags");
// Get community suggestions for this scope
const communitySuggestions = derive(
[aggregator, scope],
([agg, scopeValue]: [AggregatorCharm | null, string]) => {
if (!agg || !scopeValue) return [];
const suggs = agg.suggestions || {};
return suggs[scopeValue] || [];
},
);
// For now, local tags are just the current tags (same scope within same charm)
const localTags = derive(tags, (t: string[]) => t || []);
// Build autocomplete items combining local and community
const autocompleteItems = buildAutocompleteItems({
localTags,
communitySuggestions,
currentTags: tags,
});
// Check if aggregator is connected
const hasAggregator = derive(
aggregator,
(agg: AggregatorCharm | null) => agg != null,
);
// Computed name for display
const displayName = derive(tags, (tagList: string[]) => {
if (!tagList || tagList.length === 0) return "🏷️ Tags";
return `🏷️ Tags (${tagList.length})`;
});
return {
[NAME]: displayName,
[UI]: (
{/* Hidden render to force aggregator to execute */}
{
/* Autocomplete input - use $value binding with multiple mode
instead of onct-select (runtime bug: CustomEvent.detail not passed through ct-render)
onct-change triggers telemetry posting for additions */
}
{/* Current tags */}
{hasTags(tags)
? (
{tags.map((tag: string, index: number) => (
{tag}
))}
)
: (
No tags yet. Type to add one.
)}
{/* Aggregator status indicator */}
{hasAggregator
? "Connected to community aggregator"
: "Local mode (favorite folksonomy-aggregator for community)"}
),
tags,
addTag: addTagHandler({ tags }),
removeTag: removeTagHandler({ tags }),
};
},
);
export default FolksonomyTags;