249 lines
6.4 KiB
TypeScript

import { get, writable } from "svelte/store";
import {
createFragment as createFragmentCommand,
deleteFragment as deleteFragmentCommand,
listFragments,
updateFragment as updateFragmentCommand,
type FragmentDto,
} from "$lib/backend/fragments";
export type FragmentItem = {
id: string;
label: string;
initialContent: string;
};
export type ParsedFragment = {
title: string;
type: string;
tags: string[];
body: string;
};
const initialFragments: FragmentItem[] = [];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export const fragmentsBusyStore = writable(false);
function toStoreId(id: string): string {
return `fragments/${id}`;
}
function toBackendId(id: string): string | null {
const prefix = "fragments/";
if (!id.startsWith(prefix)) return null;
const backendId = id.slice(prefix.length).trim();
return backendId || null;
}
function splitDescription(description: string): {
title: string;
body: string;
} {
const normalized = description.trim();
if (!normalized) {
return { title: "Untitled Fragment", body: "" };
}
const separator = normalized.indexOf("\n\n");
if (separator === -1) {
return { title: normalized, body: "" };
}
const title = normalized.slice(0, separator).trim() || "Untitled Fragment";
const body = normalized.slice(separator + 2).trim();
return { title, body };
}
function composeDescription(title: string, body: string): string {
const resolvedTitle = title.trim() || "Untitled Fragment";
const resolvedBody = body.trim() || "Add details for this fragment.";
return `${resolvedTitle}\n\n${resolvedBody}`;
}
function dtoToItem(dto: FragmentDto): FragmentItem {
const parsed = splitDescription(dto.description);
return {
id: toStoreId(dto.id),
label: parsed.title,
initialContent: serializeFragment({
title: parsed.title,
type: dto.type,
tags: dto.tags ?? [],
body: parsed.body,
}),
};
}
function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) {
return [next, ...items];
}
const clone = [...items];
clone[idx] = next;
return clone;
}
export function createFragmentId(title: string): string {
const slug = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return `fragments/${slug || "fragment"}-${Date.now()}`;
}
export function serializeFragment(payload: ParsedFragment): string {
const title = payload.title.trim() || "Untitled Fragment";
const type = payload.type.trim();
const tagsLine = payload.tags.length
? payload.tags.map((tag) => `#${tag}`).join(" ")
: "(none)";
const body = payload.body.trim() || "Add details for this fragment.";
return `# ${title}\n\nType: ${type}\n\nTags: ${tagsLine}\n\n${body}`;
}
export function parseFragmentContent(
content: string,
fallbackTitle = "Untitled Fragment",
): ParsedFragment {
const headingMatch = content.match(/^#\s+(.+)$/m);
const typeMatch = content.match(/^Type:\s*(.+)$/m);
const tagsMatch = content.match(/^Tags:\s*(.+)$/m);
const bodyMatch = content.match(/^#.*\n\nType:.*\n\nTags:.*\n\n([\s\S]*)$/);
const rawTags = tagsMatch?.[1]?.trim() ?? "(none)";
const tags =
rawTags.toLowerCase() === "(none)"
? []
: rawTags
.split(/\s+/)
.map((tag) => tag.replace(/^#/, "").trim())
.filter(Boolean);
return {
title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "",
tags,
body: bodyMatch?.[1]?.trim() || "",
};
}
export function createFragmentDraft(): FragmentItem {
const id = `fragments/new-${Date.now()}`;
return {
id,
label: "New Fragment",
initialContent: "# New Fragment\n\nType: \n\nTags: (none)\n\n",
};
}
export function createFragmentItem(
title: string,
content: string,
): FragmentItem {
return {
id: createFragmentId(title),
label: title.trim() || "Untitled Fragment",
initialContent: content,
};
}
export function updateFragmentItem(
items: FragmentItem[],
id: string,
title: string,
content: string,
): FragmentItem[] {
return items.map((item) =>
item.id === id
? {
...item,
label: title.trim() || "Untitled Fragment",
initialContent: content,
}
: item,
);
}
export function prependFragmentItem(
items: FragmentItem[],
item: FragmentItem,
): FragmentItem[] {
return [item, ...items];
}
export function removeFragmentItem(
items: FragmentItem[],
id: string,
): FragmentItem[] {
return items.filter((item) => item.id !== id);
}
export async function hydrateFragments(): Promise<void> {
fragmentsBusyStore.set(true);
try {
const items = await listFragments();
fragmentsStore.set(items.map(dtoToItem));
} catch (error) {
console.error("[fragments] hydrate:error", error);
throw error;
} finally {
fragmentsBusyStore.set(false);
}
}
export async function createFragmentFromParsed(
payload: ParsedFragment,
): Promise<FragmentItem> {
const created = await createFragmentCommand({
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags,
});
const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item));
return item;
}
export async function updateFragmentFromParsed(
storeId: string,
payload: ParsedFragment,
): Promise<FragmentItem | null> {
const backendId = toBackendId(storeId);
if (!backendId) return null;
const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags,
});
if (!ok) return null;
const item: FragmentItem = {
id: storeId,
label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload),
};
fragmentsStore.update((items) => upsertById(items, item));
return item;
}
export async function deleteFragmentByStoreId(
storeId: string,
): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteFragmentCommand(backendId);
if (!ok) return false;
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
return true;
}
export function hasFragment(storeId: string): boolean {
return get(fragmentsStore).some((item) => item.id === storeId);
}