cedarstalking with keyboard shortcuts
1import {
2 Action,
3 ActionPanel,
4 Color,
5 Detail,
6 Icon,
7 Image,
8 List,
9 showToast,
10 Toast,
11} from "@raycast/api";
12import { readFile } from "fs/promises";
13import { useEffect, useRef, useState } from "react";
14import {
15 AuthRequiredError,
16 type Department,
17 type DirectoryPerson,
18 type PersonInfo,
19 type Population,
20 getDepartments,
21 getPersonInfo,
22 getPersonTerms,
23 getPopulations,
24 searchDirectory,
25} from "./api";
26import {
27 clearCookie,
28 getStoredCookie,
29 launchAuthBrowser,
30 storeCookie,
31} from "./auth";
32import { getCacheSize, mergePeopleIntoCache, searchCache } from "./cache";
33import { getCachedPhotoPath } from "./images";
34
35type AuthState =
36 | { kind: "loading" }
37 | { kind: "ready"; cookie: string }
38 | { kind: "sign-in"; signInUrl: string }
39 | { kind: "signing-in" };
40
41const CLASS_LABELS: Record<string, string> = {
42 FR: "Freshman",
43 SO: "Sophomore",
44 JR: "Junior",
45 SR: "Senior",
46 GR: "Graduate",
47 GS: "Graduate Student",
48 HS: "High School",
49 P1: "Pharmacy Year 1",
50 P2: "Pharmacy Year 2",
51 P3: "Pharmacy Year 3",
52 P4: "Pharmacy Year 4",
53};
54
55const TYPE_LABELS: Record<string, string> = {
56 UG: "Undergraduate",
57 GR: "Graduate",
58 GS: "Graduate Student",
59 DE: "Dual Enrollment",
60 P1: "Pharmacy Year 1",
61 P2: "Pharmacy Year 2",
62 P3: "Pharmacy Year 3",
63 P4: "Pharmacy Year 4",
64};
65
66function displayName(person: DirectoryPerson, showLegal = false): string {
67 const nickname =
68 person.Nickname && person.Nickname !== person.FirstName
69 ? person.Nickname
70 : null;
71 const first =
72 showLegal && nickname
73 ? `${nickname} (${person.FirstName})`
74 : (nickname ?? person.FirstName);
75 const middle = person.MiddleName ? ` ${person.MiddleName}` : "";
76 return `${first}${middle} ${person.LastName}`;
77}
78
79function email(username: string): string {
80 return `${username}@cedarville.edu`;
81}
82
83function formatPhone(phone: string): string {
84 // 4-digit campus extensions → "ext. XXXX"
85 return /^\d{4}$/.test(phone.trim()) ? `ext. ${phone.trim()}` : phone;
86}
87
88function parseSearchQuery(query: string): {
89 firstName: string;
90 lastName: string;
91} {
92 const parts = query.trim().split(/\s+/);
93 if (parts.length === 1) return { firstName: parts[0], lastName: "" };
94 const lastName = parts.pop() ?? "";
95 return { firstName: parts.join(" "), lastName };
96}
97
98// ─── Person detail view ────────────────────────────────────────────────────
99
100function formatTime(t: string): string {
101 const [h, m] = t.split(":");
102 const hour = parseInt(h, 10);
103 return `${hour > 12 ? hour - 12 : hour || 12}:${m} ${hour >= 12 ? "PM" : "AM"}`;
104}
105
106function PersonDetail({
107 person,
108 photoPath,
109 cookie,
110 onSignOut,
111}: {
112 person: DirectoryPerson;
113 photoPath: string | null;
114 cookie: string;
115 onSignOut: () => void;
116}) {
117 const name = displayName(person, true);
118 const [photoDataUrl, setPhotoDataUrl] = useState<string | null>(null);
119 const [info, setInfo] = useState<PersonInfo | null>(null);
120
121 useEffect(() => {
122 if (!photoPath) return;
123 readFile(photoPath)
124 .then((buf) =>
125 setPhotoDataUrl(`data:image/jpeg;base64,${buf.toString("base64")}`),
126 )
127 .catch(() => {});
128 }, [photoPath]);
129
130 useEffect(() => {
131 (async () => {
132 const terms = await getPersonTerms(person.Id, cookie);
133 if (!terms.length) return;
134 const now = Date.now();
135 const current =
136 terms.find((t) => {
137 const start = t.start ? new Date(t.start).getTime() : 0;
138 const end = t.end ? new Date(t.end).getTime() : Infinity;
139 return now >= start && now <= end;
140 }) ?? terms[0];
141 const result = await getPersonInfo(person.Id, current.code, cookie);
142 if (result) setInfo(result);
143 })();
144 }, [person.Id, cookie]);
145
146 // Photo full-width at top, name + italic tags below
147 const md: string[] = [];
148 if (photoDataUrl) md.push(``);
149 md.push(`# ${name}`);
150 const isStaff = !person.StudentType || !!(person.Title?.trim() && person.OfficeBuildingCode);
151 const tags: string[] = [];
152 if (isStaff) {
153 tags.push("Staff");
154 } else if (person.StudentType === "DE") {
155 tags.push("Dual Enrollment");
156 } else {
157 if (
158 person.StudentClass &&
159 CLASS_LABELS[person.StudentClass] &&
160 person.StudentClass !== "GS" &&
161 person.StudentClass !== "HS" &&
162 person.StudentType !== null
163 )
164 tags.push(CLASS_LABELS[person.StudentClass]);
165 if (person.StudentType)
166 tags.push(TYPE_LABELS[person.StudentType] ?? person.StudentType);
167 }
168 if (person.studentWorker) tags.push("Student Worker");
169 if (person.Title?.trim()) tags.push(person.Title.trim());
170 if (tags.length) md.push(`*${tags.join(" · ")}*`);
171
172 const scheduleItems = info?.faculty?.isFaculty
173 ? info.faculty.scheduleItems
174 : info?.student?.isStudent
175 ? info.student.scheduleItems
176 : [];
177 const termDesc = info?.faculty?.isFaculty
178 ? info.faculty.term?.description
179 : info?.student?.term?.description;
180
181 if (scheduleItems.length) {
182 md.push(`## Schedule${termDesc ? ` — ${termDesc}` : ""}`);
183 for (const item of scheduleItems) {
184 md.push(
185 `**${item.title}** — ${item.description} \n${item.day} ${formatTime(item.startTime)}–${formatTime(item.endTime)}`,
186 );
187 }
188 }
189
190 return (
191 <Detail
192 isLoading={!info}
193 markdown={md.join("\n\n")}
194 navigationTitle={name}
195 metadata={
196 <Detail.Metadata>
197 {person.Username && (
198 <Detail.Metadata.Label
199 title="Email"
200 text={email(person.Username)}
201 />
202 )}
203 {person.DepartmentDescription && (
204 <Detail.Metadata.Label
205 title="Department"
206 text={person.DepartmentDescription}
207 />
208 )}
209 {(isStaff ||
210 person.StudentClass ||
211 person.studentWorker) && <Detail.Metadata.Separator />}
212 {isStaff ? (
213 <Detail.Metadata.TagList title="Role">
214 <Detail.Metadata.TagList.Item text="Staff" color={Color.Green} />
215 </Detail.Metadata.TagList>
216 ) : person.StudentType === "DE" ? (
217 <Detail.Metadata.TagList title="Program">
218 <Detail.Metadata.TagList.Item
219 text="Dual Enrollment"
220 color={Color.Orange}
221 />
222 </Detail.Metadata.TagList>
223 ) : (
224 <>
225 {person.StudentClass &&
226 CLASS_LABELS[person.StudentClass] &&
227 person.StudentClass !== "GS" &&
228 person.StudentClass !== "HS" &&
229 person.StudentType !== null && (
230 <Detail.Metadata.TagList title="Year">
231 <Detail.Metadata.TagList.Item
232 text={CLASS_LABELS[person.StudentClass]}
233 color={Color.Blue}
234 />
235 </Detail.Metadata.TagList>
236 )}
237 {person.StudentType && (
238 <Detail.Metadata.Label
239 title="Program"
240 text={TYPE_LABELS[person.StudentType] ?? person.StudentType}
241 />
242 )}
243 </>
244 )}
245 {person.studentWorker && (
246 <Detail.Metadata.TagList title="Role">
247 <Detail.Metadata.TagList.Item
248 text="Student Worker"
249 color={Color.Yellow}
250 />
251 </Detail.Metadata.TagList>
252 )}
253 {(person.DormName ||
254 person.OfficeBuildingName ||
255 person.OfficePhone) && <Detail.Metadata.Separator />}
256 {person.DormName && (
257 <Detail.Metadata.Label
258 title="Dorm"
259 text={
260 person.DormRoom
261 ? `${person.DormName}, Room ${person.DormRoom}`
262 : person.DormName
263 }
264 />
265 )}
266 {person.OfficeBuildingName && (
267 <Detail.Metadata.Label
268 title="Office"
269 text={
270 person.OfficeRoom
271 ? `${person.OfficeBuildingName}, Room ${person.OfficeRoom}`
272 : person.OfficeBuildingName
273 }
274 />
275 )}
276 {person.OfficePhone && (
277 <Detail.Metadata.Label
278 title="Phone"
279 text={formatPhone(person.OfficePhone)}
280 />
281 )}
282 {(person.AddressCity || person.AddressState) && (
283 <Detail.Metadata.Separator />
284 )}
285 {(person.AddressCity || person.AddressState) && (
286 <Detail.Metadata.Label
287 title="Hometown"
288 text={[person.AddressCity, person.AddressState]
289 .filter(Boolean)
290 .join(", ")}
291 />
292 )}
293 {info?.student?.isStudent && info.student.majors.length > 0 && (
294 <>
295 <Detail.Metadata.Separator />
296 {info.student.majors.map((m) => (
297 <Detail.Metadata.Label
298 key={m.code}
299 title="Major"
300 text={m.description}
301 />
302 ))}
303 {info.student.minors.map((m) => (
304 <Detail.Metadata.Label
305 key={m.code}
306 title="Minor"
307 text={m.description}
308 />
309 ))}
310 {info.student.concentrations.map((c) => (
311 <Detail.Metadata.Label
312 key={c.code}
313 title="Concentration"
314 text={c.description}
315 />
316 ))}
317 {info.student.advisors.map((a) => (
318 <Detail.Metadata.Label
319 key={a.id}
320 title="Advisor"
321 text={a.name}
322 />
323 ))}
324 </>
325 )}
326 {info?.faculty?.isFaculty && info.faculty.facultyDepts.length > 0 && (
327 <>
328 <Detail.Metadata.Separator />
329 {info.faculty.facultyDepts.map((d) => (
330 <Detail.Metadata.Label
331 key={d.code}
332 title="Faculty Dept"
333 text={d.description}
334 />
335 ))}
336 </>
337 )}
338 <Detail.Metadata.Separator />
339 <Detail.Metadata.Label title="ID" text={person.Id} />
340 </Detail.Metadata>
341 }
342 actions={
343 <ActionPanel>
344 {person.Username && (
345 <Action.CopyToClipboard
346 title="Copy Email"
347 content={email(person.Username)}
348 />
349 )}
350 {person.OfficePhone && (
351 <Action.CopyToClipboard
352 title="Copy Phone"
353 content={formatPhone(person.OfficePhone)}
354 />
355 )}
356 <Action.CopyToClipboard
357 title="Copy ID"
358 content={person.Id}
359 icon={Icon.Person}
360 />
361 <Action.OpenInBrowser
362 title="Open Info Page"
363 url={`https://selfservice.cedarville.edu/Cedarinfo/Info?id=${person.Id}`}
364 icon={Icon.Globe}
365 />
366 <Action.CopyToClipboard
367 title="Export as JSON"
368 icon={Icon.Code}
369 content={JSON.stringify(person, null, 2)}
370 shortcut={{ modifiers: ["cmd", "shift"], key: "j" }}
371 />
372 <Action
373 title="Sign Out"
374 icon={Icon.ArrowLeft}
375 onAction={onSignOut}
376 shortcut={{ modifiers: ["cmd", "shift"], key: "s" }}
377 />
378 </ActionPanel>
379 }
380 />
381 );
382}
383
384// ─── Person list item ──────────────────────────────────────────────────────
385
386function PersonListItem({
387 person,
388 photoPath,
389 cookie,
390 onSignOut,
391}: {
392 person: DirectoryPerson;
393 photoPath: string | null;
394 cookie: string;
395 onSignOut: () => void;
396}) {
397 const name = displayName(person);
398
399 // Subtitle: title for staff, dorm for students
400 const isStudent =
401 !!person.StudentClass &&
402 !!person.StudentType &&
403 !(person.Title?.trim() && person.OfficeBuildingCode);
404 const hasOffice = !isStudent && !!person.OfficeBuildingCode;
405 const rawTitle = isStudent
406 ? person.DormName
407 ? `${person.DormName}${person.DormRoom ? ` ${person.DormRoom}` : ""}`
408 : person.Username
409 ? email(person.Username)
410 : undefined
411 : ((person.Title?.trim() ||
412 (person.Username ? email(person.Username) : undefined)) ??
413 undefined);
414 const subtitle =
415 hasOffice && rawTitle && rawTitle.length > 30
416 ? `${rawTitle.slice(0, 29)}…`
417 : rawTitle;
418
419 // Badge
420 let badge: List.Item.Accessory | null = null;
421 if (!isStudent) {
422 badge = { tag: { value: "Staff", color: Color.Green } };
423 } else if (person.StudentType === "DE") {
424 badge = { tag: { value: "DE", color: Color.Orange } };
425 } else if (person.StudentType === "GS" || person.StudentClass === "GS") {
426 badge = { tag: { value: "Graduate", color: Color.Purple } };
427 } else if (
428 person.StudentClass &&
429 CLASS_LABELS[person.StudentClass] &&
430 person.StudentClass !== "HS" &&
431 person.StudentType !== null
432 ) {
433 badge = {
434 tag: { value: CLASS_LABELS[person.StudentClass], color: Color.Blue },
435 };
436 }
437
438 // Accessories: office then badge
439 const accessories: List.Item.Accessory[] = [];
440 if (hasOffice) {
441 const officeLabel = person.OfficeRoom
442 ? `${person.OfficeBuildingCode} ${person.OfficeRoom}`
443 : person.OfficeBuildingCode!;
444 accessories.push({ text: officeLabel, icon: Icon.Building });
445 }
446 if (badge) accessories.push(badge);
447
448 return (
449 <List.Item
450 title={name}
451 subtitle={subtitle}
452 icon={
453 photoPath
454 ? {
455 source: photoPath,
456 mask: Image.Mask.Circle,
457 fallback: Icon.Person,
458 }
459 : Icon.Person
460 }
461 accessories={accessories}
462 actions={
463 <ActionPanel>
464 <Action.Push
465 title="View Details"
466 icon={Icon.Eye}
467 target={
468 <PersonDetail
469 person={person}
470 photoPath={photoPath}
471 cookie={cookie}
472 onSignOut={onSignOut}
473 />
474 }
475 />
476 {person.Username && (
477 <Action.CopyToClipboard
478 title="Copy Email"
479 content={email(person.Username)}
480 />
481 )}
482 {person.OfficePhone && (
483 <Action.CopyToClipboard
484 title="Copy Phone"
485 content={formatPhone(person.OfficePhone)}
486 />
487 )}
488 <Action.CopyToClipboard
489 title="Copy ID"
490 content={person.Id}
491 icon={Icon.Person}
492 />
493 <Action.OpenInBrowser
494 title="Open Info Page"
495 url={`https://selfservice.cedarville.edu/Cedarinfo/Info?id=${person.Id}`}
496 icon={Icon.Globe}
497 />
498 <Action.CopyToClipboard
499 title="Export as JSON"
500 icon={Icon.Code}
501 content={JSON.stringify(person, null, 2)}
502 shortcut={{ modifiers: ["cmd", "shift"], key: "j" }}
503 />
504 <Action
505 title="Sign Out"
506 icon={Icon.ArrowLeft}
507 onAction={onSignOut}
508 shortcut={{ modifiers: ["cmd", "shift"], key: "s" }}
509 />
510 </ActionPanel>
511 }
512 />
513 );
514}
515
516// ─── Main command ──────────────────────────────────────────────────────────
517
518export default function SearchDirectory() {
519 const [authState, setAuthState] = useState<AuthState>({ kind: "loading" });
520 const [query, setQuery] = useState("");
521 const [filter, setFilter] = useState("");
522 const [results, setResults] = useState<DirectoryPerson[]>([]);
523 const [photoPaths, setPhotoPaths] = useState<Record<string, string>>({});
524 const [cacheSize, setCacheSize] = useState(0);
525 const [isSearching, setIsSearching] = useState(false);
526 const [departments, setDepartments] = useState<Department[]>([]);
527 const [populations, setPopulations] = useState<Population[]>([]);
528 const searchRef = useRef<AbortController | null>(null);
529
530 useEffect(() => {
531 (async () => {
532 const cookie = await getStoredCookie();
533 if (cookie) {
534 setAuthState({ kind: "ready", cookie });
535 setCacheSize(await getCacheSize());
536 getDepartments(cookie).then(setDepartments);
537 getPopulations(cookie).then(setPopulations);
538 return;
539 }
540 try {
541 await searchDirectory("probe", "probe");
542 setAuthState({
543 kind: "sign-in",
544 signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory",
545 });
546 } catch (err) {
547 setAuthState({
548 kind: "sign-in",
549 signInUrl:
550 err instanceof AuthRequiredError
551 ? err.signInUrl
552 : "https://selfservice.cedarville.edu/cedarinfo/directory",
553 });
554 }
555 })();
556 }, []);
557
558 useEffect(() => {
559 if (authState.kind !== "ready") return;
560
561 searchRef.current?.abort();
562 const controller = new AbortController();
563 searchRef.current = controller;
564
565 // Parse filter value into API options
566 const apiOptions = filter.startsWith("dept:")
567 ? { department: filter.slice(5) }
568 : filter.startsWith("pop:")
569 ? { population: Number(filter.slice(4)) }
570 : {};
571 const hasFilter = !!filter;
572
573 // Cache search runs immediately (no debounce)
574 if (!hasFilter) {
575 searchCache(query.trim()).then((local) => {
576 if (!controller.signal.aborted) setResults(local);
577 });
578 }
579
580 const run = async () => {
581 const trimmed = query.trim();
582
583 if (!trimmed) return;
584
585 setIsSearching(true);
586 try {
587 const { firstName, lastName } = parseSearchQuery(trimmed);
588
589 // For single-word queries, search as both first and last name in parallel
590 let fresh: DirectoryPerson[];
591 if (trimmed && !lastName) {
592 const [byFirst, byLast] = await Promise.all([
593 searchDirectory(firstName, "", authState.cookie, apiOptions),
594 searchDirectory("", firstName, authState.cookie, apiOptions),
595 ]);
596 const seen = new Set<string>();
597 fresh = [];
598 for (const p of [...byFirst, ...byLast]) {
599 if (!seen.has(p.Id)) {
600 seen.add(p.Id);
601 fresh.push(p);
602 }
603 }
604 } else {
605 fresh = await searchDirectory(
606 firstName,
607 lastName,
608 authState.cookie,
609 apiOptions,
610 );
611 }
612
613 if (!controller.signal.aborted) {
614 await mergePeopleIntoCache(fresh);
615 setCacheSize(await getCacheSize());
616
617 if (hasFilter) {
618 setResults(fresh);
619 } else {
620 // Re-run cache search after merge so order stays stable (fuzzy score)
621 setResults(await searchCache(trimmed));
622 }
623 }
624 } catch (err) {
625 if (controller.signal.aborted) return;
626 if (err instanceof AuthRequiredError) {
627 await clearCookie();
628 setAuthState({ kind: "sign-in", signInUrl: err.signInUrl });
629 } else {
630 await showToast({
631 style: Toast.Style.Failure,
632 title: "Search failed",
633 message: String(err),
634 });
635 }
636 } finally {
637 if (!controller.signal.aborted) setIsSearching(false);
638 }
639 };
640
641 const timer = setTimeout(run, 300);
642 return () => {
643 clearTimeout(timer);
644 controller.abort();
645 };
646 }, [query, filter, authState]);
647
648 useEffect(() => {
649 if (authState.kind !== "ready") return;
650 const { cookie } = authState;
651 for (const person of results) {
652 if (!person.PhotoUrl || photoPaths[person.Id]) continue;
653 getCachedPhotoPath(person.Id, person.PhotoUrl, cookie).then((p) => {
654 if (p) setPhotoPaths((prev) => ({ ...prev, [person.Id]: p }));
655 });
656 }
657 }, [results, authState]);
658
659 async function handleSignOut() {
660 await clearCookie();
661 setResults([]);
662 try {
663 await searchDirectory("probe", "probe");
664 setAuthState({
665 kind: "sign-in",
666 signInUrl: "https://selfservice.cedarville.edu/cedarinfo/directory",
667 });
668 } catch (err) {
669 setAuthState({
670 kind: "sign-in",
671 signInUrl:
672 err instanceof AuthRequiredError
673 ? err.signInUrl
674 : "https://selfservice.cedarville.edu/cedarinfo/directory",
675 });
676 }
677 }
678
679 async function handleSignIn(signInUrl: string) {
680 setAuthState({ kind: "signing-in" });
681 const toast = await showToast({
682 style: Toast.Style.Animated,
683 title: "Opening sign-in window…",
684 message: "Complete login in the window that opens",
685 });
686 try {
687 const cookie = await launchAuthBrowser(signInUrl);
688 await storeCookie(cookie);
689 toast.style = Toast.Style.Success;
690 toast.title = "Signed in!";
691 setCacheSize(await getCacheSize());
692 getDepartments(cookie).then(setDepartments);
693 getPopulations(cookie).then(setPopulations);
694 setAuthState({ kind: "ready", cookie });
695 } catch (err) {
696 toast.style = Toast.Style.Failure;
697 toast.title = String(err);
698 setAuthState({ kind: "sign-in", signInUrl });
699 }
700 }
701
702 // ── Auth screens ────────────────────────────────────────────────────────
703
704 if (authState.kind === "loading") return <List isLoading />;
705
706 if (authState.kind === "sign-in") {
707 return (
708 <List>
709 <List.EmptyView
710 title="Sign in to Cedarville"
711 description="A small sign-in window will open. No browser or cookies are touched."
712 icon={Icon.Lock}
713 actions={
714 <ActionPanel>
715 <Action
716 title="Sign In"
717 icon={Icon.Person}
718 onAction={() => handleSignIn(authState.signInUrl)}
719 />
720 </ActionPanel>
721 }
722 />
723 </List>
724 );
725 }
726
727 if (authState.kind === "signing-in") {
728 return (
729 <List isLoading>
730 <List.EmptyView
731 title="Waiting for sign-in…"
732 description="Complete login in the window that just opened."
733 icon={Icon.Clock}
734 />
735 </List>
736 );
737 }
738
739 // ── Ready: search ───────────────────────────────────────────────────────
740
741 return (
742 <List
743 isLoading={isSearching}
744 searchBarPlaceholder="Search by name…"
745 onSearchTextChange={setQuery}
746 throttle={false}
747 searchBarAccessory={
748 <List.Dropdown tooltip="Filter" value={filter} onChange={setFilter}>
749 <List.Dropdown.Item title="All People" value="" />
750 {populations.length > 0 && (
751 <List.Dropdown.Section title="By Type">
752 {populations.map((p) => (
753 <List.Dropdown.Item
754 key={p.code}
755 title={p.desc}
756 value={`pop:${p.code}`}
757 />
758 ))}
759 </List.Dropdown.Section>
760 )}
761 {departments.length > 0 && (
762 <List.Dropdown.Section title="By Department">
763 {departments.map((d) => (
764 <List.Dropdown.Item
765 key={d.code}
766 title={d.description}
767 value={`dept:${d.code}`}
768 />
769 ))}
770 </List.Dropdown.Section>
771 )}
772 </List.Dropdown>
773 }
774 >
775 {results.length === 0 ? (
776 <List.EmptyView
777 title={
778 query.trim()
779 ? "No results found"
780 : "Search the Cedarville Directory"
781 }
782 description={
783 query.trim()
784 ? `No one matched "${query}"`
785 : cacheSize > 0
786 ? `${cacheSize} people cached`
787 : "Start typing to search"
788 }
789 icon={query.trim() ? Icon.MagnifyingGlass : Icon.Person}
790 />
791 ) : (
792 results.map((person) => (
793 <PersonListItem
794 key={person.Id}
795 person={person}
796 photoPath={photoPaths[person.Id] ?? null}
797 cookie={authState.cookie}
798 onSignOut={handleSignOut}
799 />
800 ))
801 )}
802 </List>
803 );
804}