249 lines
6.4 KiB
TypeScript
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);
|
|
}
|