This commit is contained in:
35
apps/web/src/app/api/resource-links/[id]/route.ts
Normal file
35
apps/web/src/app/api/resource-links/[id]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { deleteResourceLink, updateResourceLink } from "@/lib/services/resource-links";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const link = await updateResourceLink({
|
||||
userId: user.id,
|
||||
id,
|
||||
title: typeof body.title === "string" ? body.title : undefined,
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
category: typeof body.category === "string" ? body.category : undefined,
|
||||
notes: typeof body.notes === "string" || body.notes === null ? body.notes : undefined,
|
||||
status: typeof body.status === "string" ? body.status : undefined,
|
||||
});
|
||||
return { link };
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return deleteResourceLink(user.id, id);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/resource-links/export/route.ts
Normal file
15
apps/web/src/app/api/resource-links/export/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { exportResourceLinksToBacklog } from "@/lib/services/resource-links";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return exportResourceLinksToBacklog({
|
||||
userId: user.id,
|
||||
weekStart: String(body.weekStart ?? ""),
|
||||
ids: Array.isArray(body.ids) ? body.ids.map(String) : [],
|
||||
});
|
||||
});
|
||||
}
|
||||
40
apps/web/src/app/api/resource-links/route.ts
Normal file
40
apps/web/src/app/api/resource-links/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
createResourceLink,
|
||||
getResourceBacklogSections,
|
||||
listResourceLinks,
|
||||
type ResourceLinkStatus,
|
||||
} from "@/lib/services/resource-links";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const weekStart = searchParams.get("weekStart") ?? undefined;
|
||||
const status = (searchParams.get("status") ?? undefined) as ResourceLinkStatus | undefined;
|
||||
const includeSections = searchParams.get("includeSections") === "1";
|
||||
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const [links, sections] = await Promise.all([
|
||||
listResourceLinks(user.id, { weekStart, status }),
|
||||
includeSections ? getResourceBacklogSections() : Promise.resolve([]),
|
||||
]);
|
||||
return { links, sections };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const link = await createResourceLink({
|
||||
userId: user.id,
|
||||
date: String(body.date),
|
||||
title: String(body.title ?? ""),
|
||||
url: String(body.url ?? ""),
|
||||
category: typeof body.category === "string" ? body.category : undefined,
|
||||
notes: typeof body.notes === "string" ? body.notes : undefined,
|
||||
});
|
||||
return { link };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { CharacterCard } from "@/components/features/character-card";
|
||||
@@ -10,6 +10,7 @@ import { QuestGiverSidebar, ReadingWidget } from "@/components/features/sidebar-
|
||||
import { DaySwitcher } from "@/components/features/day-switcher";
|
||||
import { CatchUpCard } from "@/components/features/catch-up-card";
|
||||
import { QuickLogPanel } from "@/components/features/quick-log-panel";
|
||||
import { ResourceLinkCapture } from "@/components/features/resource-link-capture";
|
||||
import { formatDisplayDate, todayString } from "@/lib/dates-client";
|
||||
|
||||
async function fetchDashboard(date?: string) {
|
||||
@@ -25,6 +26,9 @@ export default function HomePage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["dashboard", activeDate],
|
||||
queryFn: () => fetchDashboard(activeDate),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -100,6 +104,7 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
<QuickLogPanel date={date} />
|
||||
<ResourceLinkCapture date={date} />
|
||||
<DailyReflection
|
||||
date={date}
|
||||
initial={data.reflection}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState } from "react";
|
||||
import { weekStartString } from "@/lib/dates-client";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
import { ResourceLinkReview } from "@/components/features/resource-link-review";
|
||||
|
||||
export default function ReviewPage() {
|
||||
const weekStart = weekStartString();
|
||||
@@ -60,15 +61,20 @@ export default function ReviewPage() {
|
||||
if (!review) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-8 text-center max-w-md mx-auto">
|
||||
<h1 className="font-bold text-lg mb-4">Weekly Review</h1>
|
||||
<p className="serif mb-4">
|
||||
Your weekly chapter is ready to be written. Generate your review to see patterns,
|
||||
progress, and a letter from the Guide.
|
||||
</p>
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => generate.mutate()}>
|
||||
Generate This Week's Review
|
||||
</button>
|
||||
<div className="p-4 pb-20 md:pb-4 min-h-full parchment-bg">
|
||||
<div className="p-8 text-center max-w-md mx-auto">
|
||||
<h1 className="font-bold text-lg mb-4">Weekly Review</h1>
|
||||
<p className="serif mb-4">
|
||||
Your weekly chapter is ready to be written. Generate your review to see patterns,
|
||||
progress, and a letter from the Guide.
|
||||
</p>
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => generate.mutate()}>
|
||||
Generate This Week's Review
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto mt-4">
|
||||
<ResourceLinkReview weekStart={weekStart} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
@@ -162,6 +168,9 @@ export default function ReviewPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto mt-4">
|
||||
<ResourceLinkReview weekStart={weekStart} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
132
apps/web/src/components/features/resource-link-capture.tsx
Normal file
132
apps/web/src/components/features/resource-link-capture.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
type ResourceSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type ResourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
interface ResourceLinkCaptureProps {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function ResourceLinkCapture({ date }: ResourceLinkCaptureProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [category, setCategory] = useState("Interesting things to read up:");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["resource-links", "capture"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/resource-links?status=inbox&includeSections=1");
|
||||
if (!res.ok) throw new Error("Failed to load resource links");
|
||||
return res.json() as Promise<{ links: ResourceLink[]; sections: ResourceSection[] }>;
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sections = data?.sections?.length
|
||||
? data.sections
|
||||
: [{ title: "Interesting things to read up:", level: 1 }];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/resource-links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date, title, url, category }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Failed to save link");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTitle("");
|
||||
setUrl("");
|
||||
qc.invalidateQueries({ queryKey: ["resource-links"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="retro-btn text-xs w-full" onClick={() => setOpen(true)}>
|
||||
Capture resource link
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-window border border-[var(--warm-grey)]/30">
|
||||
<div className="retro-titlebar text-sm">Resource Inbox</div>
|
||||
<div className="p-3 space-y-2 text-sm">
|
||||
<input
|
||||
className="retro-input w-full text-xs"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="retro-input w-full text-xs"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="retro-window-inset w-full p-1 text-xs"
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.title} value={section.title}>
|
||||
{section.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{save.error && (
|
||||
<p className="text-xs text-[var(--muted-rose)]">{save.error.message}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs flex-1"
|
||||
disabled={save.isPending || !title.trim() || !url.trim()}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving..." : "Save link"}
|
||||
</button>
|
||||
<button type="button" className="retro-btn text-xs" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{data?.links?.length ? (
|
||||
<div className="pt-2 border-t border-[var(--warm-grey)]/25">
|
||||
<p className="text-xs font-bold text-[var(--warm-grey)] mb-1">Inbox</p>
|
||||
<ul className="space-y-1">
|
||||
{data.links.slice(0, 4).map((link) => (
|
||||
<li key={link.id} className="text-xs truncate">
|
||||
{link.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
apps/web/src/components/features/resource-link-review.tsx
Normal file
179
apps/web/src/components/features/resource-link-review.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
type ResourceSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type ResourceLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string;
|
||||
status: "inbox" | "exported" | "dismissed";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
interface ResourceLinkReviewProps {
|
||||
weekStart: string;
|
||||
}
|
||||
|
||||
export function ResourceLinkReview({ weekStart }: ResourceLinkReviewProps) {
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["resource-links", weekStart],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/resource-links?weekStart=${weekStart}&status=inbox&includeSections=1`
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load resource links");
|
||||
return res.json() as Promise<{ links: ResourceLink[]; sections: ResourceSection[] }>;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const sections = data?.sections?.length
|
||||
? data.sections
|
||||
: [{ title: "Interesting things to read up:", level: 1 }];
|
||||
|
||||
const links = data?.links ?? [];
|
||||
|
||||
const updateCategory = useMutation({
|
||||
mutationFn: async ({ id, category }: { id: string; category: string }) => {
|
||||
const res = await fetch(`/api/resource-links/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ category }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update link");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["resource-links", weekStart] }),
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/resource-links/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "dismissed" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to dismiss link");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["resource-links", weekStart] }),
|
||||
});
|
||||
|
||||
const exportLinks = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/resource-links/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ weekStart, ids: Array.from(selected) }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error ?? "Failed to export links");
|
||||
return data as { exported: number; path: string; weekLabel: string };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
qc.invalidateQueries({ queryKey: ["resource-links", weekStart] });
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-window p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h2 className="font-bold">Resource Links</h2>
|
||||
<p className="text-xs text-[var(--warm-grey)]">Week of {weekStart}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs"
|
||||
disabled={selected.size === 0 || exportLinks.isPending}
|
||||
onClick={() => exportLinks.mutate()}
|
||||
>
|
||||
{exportLinks.isPending ? "Exporting..." : `Export ${selected.size || ""}`.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-[var(--warm-grey)]">Loading links...</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm text-[var(--warm-grey)]">No inbox links for this week.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.map((link) => (
|
||||
<div key={link.id} className="retro-window-inset p-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={selected.has(link.id)}
|
||||
onChange={() => toggle(link.id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-bold underline break-words"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
<p className="text-xs text-[var(--warm-grey)] truncate">{link.url}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<select
|
||||
className="retro-window-inset p-1 text-xs max-w-full"
|
||||
value={link.category}
|
||||
onChange={(event) =>
|
||||
updateCategory.mutate({ id: link.id, category: event.target.value })
|
||||
}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.title} value={section.title}>
|
||||
{section.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => dismiss.mutate(link.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportLinks.error && (
|
||||
<p className="mt-2 text-xs text-[var(--muted-rose)]">{exportLinks.error.message}</p>
|
||||
)}
|
||||
{exportLinks.data && (
|
||||
<p className="mt-2 text-xs text-[var(--warm-grey)]">
|
||||
Exported {exportLinks.data.exported} links to ## {exportLinks.data.weekLabel}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,23 +20,36 @@ export async function getDashboard(date?: string) {
|
||||
const boundaryHour = await getDayBoundaryHour(user.id);
|
||||
const logicalToday = getLogicalToday(boundaryHour);
|
||||
const activeDate = date ?? logicalToday;
|
||||
await materializeAndVisit(user.id, activeDate);
|
||||
|
||||
const { adventure, items, todos } = await getDailyAdventure(user.id, activeDate);
|
||||
const reflection = await getReflection(user.id, activeDate);
|
||||
const suggestions = await getSuggestions(user.id, "quest_giver");
|
||||
const bookList = await getBooks(user.id);
|
||||
const streak = await getReadingStreak(user.id);
|
||||
const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd");
|
||||
const weeklyPages = await getWeeklyPages(user.id, weekStart, activeDate);
|
||||
const catchUpGaps = await detectCatchUpGaps(user.id);
|
||||
|
||||
const [goalRow] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
);
|
||||
const [
|
||||
{ adventure, items, todos },
|
||||
reflection,
|
||||
suggestions,
|
||||
bookList,
|
||||
streak,
|
||||
weeklyPages,
|
||||
catchUpGaps,
|
||||
goalRows,
|
||||
] = await Promise.all([
|
||||
getDailyAdventure(user.id, activeDate),
|
||||
getReflection(user.id, activeDate),
|
||||
getSuggestions(user.id, "quest_giver"),
|
||||
getBooks(user.id),
|
||||
getReadingStreak(user.id),
|
||||
getWeeklyPages(user.id, weekStart, activeDate),
|
||||
detectCatchUpGaps(user.id),
|
||||
db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
),
|
||||
]);
|
||||
|
||||
void recordDashboardVisit(user.id, activeDate);
|
||||
|
||||
const [goalRow] = goalRows;
|
||||
|
||||
const activeBooks = bookList
|
||||
.filter((b) => b.status === "reading")
|
||||
@@ -133,14 +146,17 @@ export async function getDashboard(date?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeAndVisit(userId: string, date: string) {
|
||||
await getDailyAdventure(userId, date);
|
||||
await awardDailyVisit(userId, date);
|
||||
await refreshScores(userId);
|
||||
const existing = await getAchievements(userId);
|
||||
if (existing.length === 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_visit");
|
||||
async function recordDashboardVisit(userId: string, date: string) {
|
||||
try {
|
||||
await awardDailyVisit(userId, date);
|
||||
await refreshScores(userId);
|
||||
const existing = await getAchievements(userId);
|
||||
if (existing.length === 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_visit");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Dashboard visit bookkeeping failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
258
apps/web/src/lib/services/resource-links.ts
Normal file
258
apps/web/src/lib/services/resource-links.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db, resourceLinks } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
|
||||
const RESOURCE_BACKLOG_PATH =
|
||||
process.env.RESOURCE_BACKLOG_PATH ??
|
||||
"/home/zaine/master-folder/vault-master/Non Technical/z resources.md";
|
||||
|
||||
export const DEFAULT_RESOURCE_CATEGORY = "Interesting things to read up:";
|
||||
|
||||
export type ResourceLinkStatus = "inbox" | "exported" | "dismissed";
|
||||
|
||||
export type ResourceBacklogSection = {
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export async function createResourceLink(input: {
|
||||
userId: string;
|
||||
date: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const url = input.url.trim();
|
||||
if (!title) throw new Error("Title is required");
|
||||
if (!isValidUrl(url)) throw new Error("A valid URL is required");
|
||||
|
||||
const [row] = await db
|
||||
.insert(resourceLinks)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
date: input.date,
|
||||
weekStart: weekStartString(parseISO(input.date)),
|
||||
title,
|
||||
url,
|
||||
category: input.category?.trim() || DEFAULT_RESOURCE_CATEGORY,
|
||||
notes: input.notes?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listResourceLinks(userId: string, options?: {
|
||||
weekStart?: string;
|
||||
status?: ResourceLinkStatus;
|
||||
}) {
|
||||
const predicates = [eq(resourceLinks.userId, userId)];
|
||||
if (options?.weekStart) predicates.push(eq(resourceLinks.weekStart, options.weekStart));
|
||||
if (options?.status) predicates.push(eq(resourceLinks.status, options.status));
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(resourceLinks)
|
||||
.where(and(...predicates))
|
||||
.orderBy(desc(resourceLinks.createdAt));
|
||||
}
|
||||
|
||||
export async function updateResourceLink(input: {
|
||||
userId: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
category?: string;
|
||||
notes?: string | null;
|
||||
status?: ResourceLinkStatus;
|
||||
}) {
|
||||
const updates: Partial<typeof resourceLinks.$inferInsert> = {};
|
||||
if (input.title !== undefined) {
|
||||
const title = input.title.trim();
|
||||
if (!title) throw new Error("Title is required");
|
||||
updates.title = title;
|
||||
}
|
||||
if (input.url !== undefined) {
|
||||
const url = input.url.trim();
|
||||
if (!isValidUrl(url)) throw new Error("A valid URL is required");
|
||||
updates.url = url;
|
||||
}
|
||||
if (input.category !== undefined) {
|
||||
updates.category = input.category.trim() || DEFAULT_RESOURCE_CATEGORY;
|
||||
}
|
||||
if (input.notes !== undefined) updates.notes = input.notes?.trim() || null;
|
||||
if (input.status !== undefined) updates.status = input.status;
|
||||
|
||||
const [row] = await db
|
||||
.update(resourceLinks)
|
||||
.set(updates)
|
||||
.where(and(eq(resourceLinks.userId, input.userId), eq(resourceLinks.id, input.id)))
|
||||
.returning();
|
||||
if (!row) throw new Error("Resource link not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteResourceLink(userId: string, id: string) {
|
||||
await db
|
||||
.delete(resourceLinks)
|
||||
.where(and(eq(resourceLinks.userId, userId), eq(resourceLinks.id, id)));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function getResourceBacklogSections(): Promise<ResourceBacklogSection[]> {
|
||||
const markdown = await readBacklog();
|
||||
return parseTopLevelSections(markdown);
|
||||
}
|
||||
|
||||
export async function exportResourceLinksToBacklog(input: {
|
||||
userId: string;
|
||||
weekStart: string;
|
||||
ids: string[];
|
||||
}) {
|
||||
if (input.ids.length === 0) throw new Error("Select at least one link to export");
|
||||
|
||||
const links = await db
|
||||
.select()
|
||||
.from(resourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLinks.userId, input.userId),
|
||||
eq(resourceLinks.weekStart, input.weekStart),
|
||||
eq(resourceLinks.status, "inbox"),
|
||||
inArray(resourceLinks.id, input.ids)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(resourceLinks.createdAt));
|
||||
|
||||
if (links.length === 0) throw new Error("No inbox links found for export");
|
||||
|
||||
let markdown = await readBacklog();
|
||||
const weekLabel = format(parseISO(input.weekStart), "dd/MM/yy");
|
||||
|
||||
const byCategory = new Map<string, typeof links>();
|
||||
for (const link of links) {
|
||||
const category = link.category || DEFAULT_RESOURCE_CATEGORY;
|
||||
byCategory.set(category, [...(byCategory.get(category) ?? []), link]);
|
||||
}
|
||||
|
||||
for (const [category, categoryLinks] of byCategory) {
|
||||
const lines = categoryLinks.map(formatMarkdownLink);
|
||||
markdown = appendLinksToSection(markdown, category, weekLabel, lines);
|
||||
}
|
||||
|
||||
await fs.writeFile(RESOURCE_BACKLOG_PATH, markdown, "utf8");
|
||||
await db
|
||||
.update(resourceLinks)
|
||||
.set({ status: "exported", exportedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLinks.userId, input.userId),
|
||||
inArray(resourceLinks.id, links.map((link) => link.id))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
exported: links.length,
|
||||
path: RESOURCE_BACKLOG_PATH,
|
||||
weekLabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBacklog() {
|
||||
try {
|
||||
return await fs.readFile(RESOURCE_BACKLOG_PATH, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTopLevelSections(markdown: string): ResourceBacklogSection[] {
|
||||
const sections = markdown
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^(#{1})\s+(.+?)\s*$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => ({ level: 1, title: match[2] }));
|
||||
|
||||
if (sections.length === 0) {
|
||||
return [{ level: 1, title: DEFAULT_RESOURCE_CATEGORY }];
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function appendLinksToSection(
|
||||
markdown: string,
|
||||
category: string,
|
||||
weekLabel: string,
|
||||
links: string[]
|
||||
) {
|
||||
const normalizedCategory = normalizeHeading(category);
|
||||
const lines = markdown.replace(/\s*$/, "\n").split(/\r?\n/);
|
||||
const categoryIndex = lines.findIndex(
|
||||
(line) => line.startsWith("# ") && normalizeHeading(line.slice(2)) === normalizedCategory
|
||||
);
|
||||
|
||||
if (categoryIndex === -1) {
|
||||
lines.push("", `# ${category}`, "", `## ${weekLabel}`, ...links);
|
||||
return lines.join("\n").replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
const nextCategoryIndex = findNextTopLevelHeading(lines, categoryIndex + 1);
|
||||
const categoryEnd = nextCategoryIndex === -1 ? lines.length : nextCategoryIndex;
|
||||
const weekIndex = lines.findIndex(
|
||||
(line, index) =>
|
||||
index > categoryIndex &&
|
||||
index < categoryEnd &&
|
||||
line.startsWith("## ") &&
|
||||
normalizeHeading(line.slice(3)) === normalizeHeading(weekLabel)
|
||||
);
|
||||
|
||||
if (weekIndex !== -1) {
|
||||
const insertAt = findSectionEnd(lines, weekIndex + 1, categoryEnd);
|
||||
lines.splice(insertAt, 0, ...links);
|
||||
} else {
|
||||
lines.splice(categoryEnd, 0, "", `## ${weekLabel}`, ...links);
|
||||
}
|
||||
|
||||
return lines.join("\n").replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
function findNextTopLevelHeading(lines: string[], start: number) {
|
||||
for (let i = start; i < lines.length; i += 1) {
|
||||
if (lines[i].startsWith("# ")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSectionEnd(lines: string[], start: number, max: number) {
|
||||
for (let i = start; i < max; i += 1) {
|
||||
if (lines[i].startsWith("## ")) return i;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function formatMarkdownLink(link: typeof resourceLinks.$inferSelect) {
|
||||
const title = escapeMarkdownLinkText(link.title);
|
||||
const url = link.url.replace(/>/g, "%3E");
|
||||
return `- [ ] [${title}](<${url}>)`;
|
||||
}
|
||||
|
||||
function escapeMarkdownLinkText(text: string) {
|
||||
return text.replace(/[[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeHeading(text: string) {
|
||||
return text.trim().replace(/:$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isValidUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user