Initial commit: OL Rescue Outlook add-in (New Outlook + Graph)

Made-with: Cursor
This commit is contained in:
2026-04-06 23:01:07 -07:00
commit 21e2ac98cd
18 changed files with 1062 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Azure AD app registration (single-page application). See README.
VITE_AZURE_CLIENT_ID=

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Dependencies
node_modules/
# Production build
dist/
build/
# TypeScript
*.tsbuildinfo
# Environment — never commit secrets; template is committed
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Test / coverage
coverage/
.nyc_output/
# Caches
.cache/
.vite/
.parcel-cache/
.eslintcache
*.cache
# IDE / editors (keep team-shared .vscode/*.json in repo if you add them with !)
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Optional local HTTPS / signing material (never commit real certs)
*.pem
*.p12
*.pfx
# Archives from npm pack / releases
*.tgz

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# OL Rescue
Outlook **Office Web Add-in** that recreates the old “OL Helper” workflow for **New Outlook** (and Outlook on the web): you define a **Tracking ID** and an **Inbox subfolder name**, then the add-in uses **Microsoft Graph** to create the folder (if needed) and move messages that contain `TrackingID#…` in the subject or preview.
Classic COM/VSTO add-ins do not run in New Outlook. The supported path is **Office.js + Graph**, which this project uses.
## What you need
- **Node.js 20+** and npm (for local HTTPS dev and builds).
- **Microsoft 365 / Outlook** with add-ins allowed by your org.
- An **Azure AD app registration** (see below) with Graph delegated permissions: **Mail.ReadWrite**, **User.Read**.
## Azure AD app (summary)
1. In [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID****App registrations****New registration**.
2. Name it (e.g. “OL Rescue”), supported account types per your tenant, **Redirect URI**: platform **Single-page application (SPA)**, URI `https://localhost:3000` (and later your production URL).
3. **API permissions****Microsoft Graph****Delegated**: add **Mail.ReadWrite**, **User.Read****Grant admin consent** if required.
4. **Overview** → copy **Application (client) ID**.
## Run locally
```bash
cd "/path/to/OL Rescue!"
cp .env.example .env
# Set VITE_AZURE_CLIENT_ID to your client ID
npm install
npm run dev
```
Vite serves **HTTPS** on port **3000** (self-signed cert; your browser will warn once).
## Sideload the manifest in Outlook (Windows New Outlook)
1. Ensure `manifest.xml` still points to `https://localhost:3000/index.html` (default for dev).
2. In Outlook: **File****Manage Add-ins** / **Get Add-ins****My add-ins****Add a custom add-in****Add from file…** → choose `manifest.xml`.
If your org blocks custom add-ins, an admin may need to deploy via **Centralized Deployment** or allow sideloading.
## Use the add-in
1. Open a ticket email and open the **OL Rescue** task pane.
2. **Sign in with Microsoft** (Graph consent).
3. **Fill from open message** to parse `TrackingID#…` and suggest a folder name, or type **Tracking ID** and **Inbox folder name** manually.
4. **Save case**, then **Create folder & move mail** on that case to create `Inbox\<folder name>` and move matching messages.
Saved cases are stored in **browser localStorage** for this add-in origin.
## Production
1. Host the `dist/` folder (or your static host) over **HTTPS**.
2. Update **Application ID URI / redirect URIs** in Azure for your real origin.
3. Edit **manifest.xml** `SourceLocation`, `IconUrl`, and `SupportUrl` to your HTTPS URLs, then rebuild (`npm run build`) and redeploy.
## Limits vs old OL Helper
- **Not automatic on every new message** unless you add something else (e.g. Outlook rules calling a service, or Power Automate). This tool is **on-demand** from the task pane.
- **Graph search** uses `$filter` on subject/body preview; very unusual formats might need a small follow-up change.
- **Shared mailboxes** need extra Graph permissions and possibly UX changes; this MVP targets your primary mailbox (`/me/…`).
## License
Use and modify for your work as needed.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OL Rescue</title>
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

39
manifest.xml Normal file
View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:type="MailApp">
<Id>0b5e2d6a-8a1c-4f2e-9e0b-4a1c2d3e4f5a</Id>
<Version>1.0.0.0</Version>
<ProviderName>OL Rescue</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="OL Rescue"/>
<Description DefaultValue="File mail by TrackingID into Inbox subfolders for New Outlook (Microsoft Graph)."/>
<IconUrl DefaultValue="https://localhost:3000/icon-32.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/icon-128.png"/>
<SupportUrl DefaultValue="https://localhost:3000"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
<AppDomain>https://login.microsoftonline.com</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.8"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/index.html"/>
<RequestedHeight>450</RequestedHeight>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="Rule" FormType="Read">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
</OfficeApp>

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "ol-rescue",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Outlook add-in: file mail by internal TrackingID into named folders (New Outlook / web)",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@azure/msal-browser": "^4.27.0"
},
"devDependencies": {
"@types/office-js": "^1.0.534",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^1.2.0"
}
}

BIN
public/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
public/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

329
src/app.ts Normal file
View File

@@ -0,0 +1,329 @@
import {
createMsal,
ensureActiveAccount,
getActiveAccount,
getGraphAccessToken,
initializeAuth,
signIn,
signOut,
} from "./auth";
import {
findMessagesWithTrackingId,
findOrCreateInboxChildFolder,
moveMessageToFolder,
} from "./graph";
import { extractTrackingId, suggestFolderDisplayName } from "./parse";
import { type CaseEntry, loadCases, saveCases } from "./storage";
import "./styles.css";
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Record<string, string | undefined> = {},
children: (Node | string)[] = [],
): HTMLElement {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v === undefined) {
continue;
}
if (k === "className") {
node.className = v;
} else if (k.startsWith("on") && typeof v === "string") {
// not used
} else {
node.setAttribute(k, v);
}
}
for (const c of children) {
node.append(typeof c === "string" ? document.createTextNode(c) : c);
}
return node;
}
function getClientId(): string | undefined {
const id = import.meta.env.VITE_AZURE_CLIENT_ID?.trim();
return id || undefined;
}
export async function initApp(): Promise<void> {
const root = document.getElementById("app");
if (!root) {
return;
}
const clientId = getClientId();
if (!clientId) {
root.replaceChildren(
el("div", { className: "panel" }, [
el("h1", {}, ["OL Rescue"]),
el("p", {}, [
"Add your Azure AD app client ID to ",
el("code", {}, [".env"]),
" as ",
el("code", {}, ["VITE_AZURE_CLIENT_ID"]),
", then restart ",
el("code", {}, ["npm run dev"]),
".",
]),
]),
);
return;
}
const msal = createMsal(clientId);
await initializeAuth(msal);
ensureActiveAccount(msal);
let cases = loadCases();
const statusEl = el("div", { className: "status", role: "status" });
const logEl = el("pre", { className: "log" });
function setStatus(msg: string): void {
statusEl.textContent = msg;
}
function appendLog(line: string): void {
logEl.textContent = `${logEl.textContent ?? ""}${line}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
const trackingInput = el("input", {
type: "text",
className: "input",
placeholder: "e.g. 2603260030007249",
"aria-label": "Tracking ID",
}) as HTMLInputElement;
const folderInput = el("input", {
type: "text",
className: "input",
placeholder: 'e.g. 2603260030007249 Crazy Netflix Case',
"aria-label": "Folder name under Inbox",
}) as HTMLInputElement;
const caseList = el("div", { className: "case-list" });
function persist(): void {
saveCases(cases);
}
function renderCaseList(): void {
caseList.replaceChildren();
if (cases.length === 0) {
caseList.append(el("p", { className: "muted" }, ["No saved cases yet."]));
return;
}
for (const c of cases) {
const row = el("div", { className: "case-row" });
row.append(
el("div", { className: "case-meta" }, [
el("strong", {}, [c.folderDisplayName]),
el("span", { className: "muted" }, [` · TrackingID#${c.trackingId}`]),
]),
);
const actions = el("div", { className: "case-actions" });
const syncBtn = el("button", { type: "button", className: "btn btn-small" }, [
"Create folder & move mail",
]);
syncBtn.addEventListener("click", () => void syncCase(c.id));
const removeBtn = el("button", { type: "button", className: "btn btn-small btn-ghost" }, [
"Remove",
]);
removeBtn.addEventListener("click", () => {
cases = cases.filter((x) => x.id !== c.id);
persist();
renderCaseList();
});
actions.append(syncBtn, removeBtn);
row.append(actions);
caseList.append(row);
}
}
async function syncCase(caseId: string): Promise<void> {
const entry = cases.find((c) => c.id === caseId);
if (!entry) {
return;
}
setStatus("Working…");
appendLog(`${entry.folderDisplayName}`);
try {
const token = await getGraphAccessToken(msal);
appendLog("Ensuring folder exists under Inbox…");
const folder = await findOrCreateInboxChildFolder(token, entry.folderDisplayName);
entry.graphFolderId = folder.id;
persist();
appendLog(`Finding messages with TrackingID#${entry.trackingId}`);
const messages = await findMessagesWithTrackingId(token, entry.trackingId);
appendLog(`Found ${messages.length} message(s).`);
let moved = 0;
for (const m of messages) {
try {
await moveMessageToFolder(token, m.id, folder.id);
moved += 1;
appendLog(`Moved: ${m.subject ?? m.id}`);
} catch (e) {
appendLog(`Skip/fail: ${m.subject ?? m.id}${e instanceof Error ? e.message : String(e)}`);
}
}
appendLog(`Done. Moved ${moved} message(s).`);
setStatus(`Last run: moved ${moved} of ${messages.length}.`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
appendLog(`Error: ${msg}`);
setStatus("Failed — see log.");
}
}
async function fillFromOpenMessage(): Promise<void> {
const item = Office.context.mailbox.item;
if (!item) {
setStatus("Open a message first.");
return;
}
setStatus("Reading open message…");
await new Promise<void>((resolve, reject) => {
item.subject.getAsync((r) => {
if (r.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(r.error?.message ?? "Could not read subject."));
return;
}
const subject = r.value ?? "";
const id = extractTrackingId(subject);
if (id) {
trackingInput.value = id;
folderInput.value = suggestFolderDisplayName(subject, id);
setStatus("Filled from subject.");
} else {
item.body.getAsync(Office.CoercionType.Text, (br) => {
if (br.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(br.error?.message ?? "Could not read body."));
return;
}
const bodyText = br.value ?? "";
const id2 = extractTrackingId(bodyText);
if (id2) {
trackingInput.value = id2;
folderInput.value = suggestFolderDisplayName(subject || "Case", id2);
setStatus("Filled from body text.");
} else {
setStatus("No TrackingID# found in subject or body.");
}
resolve();
});
return;
}
resolve();
});
}).catch((e) => {
setStatus(e instanceof Error ? e.message : String(e));
});
}
function addOrUpdateCase(): void {
const trackingId = trackingInput.value.trim();
const folderDisplayName = folderInput.value.trim();
if (!/^\d+$/.test(trackingId)) {
setStatus("Tracking ID should be digits only (from TrackingID#…).");
return;
}
if (!folderDisplayName) {
setStatus("Enter a folder name.");
return;
}
const existing = cases.find((c) => c.trackingId === trackingId);
if (existing) {
existing.folderDisplayName = folderDisplayName;
} else {
cases.push({
id: crypto.randomUUID(),
trackingId,
folderDisplayName,
});
}
persist();
renderCaseList();
setStatus("Case saved.");
}
const authBar = el("div", { className: "auth-bar" });
const userLabel = el("span", { className: "muted" });
function renderAuth(): void {
authBar.replaceChildren();
const acc = getActiveAccount(msal);
if (acc) {
userLabel.textContent = acc.username ?? acc.name ?? "Signed in";
const outBtn = el("button", { type: "button", className: "btn btn-ghost" }, ["Sign out"]);
outBtn.addEventListener("click", () => {
signOut(msal);
userLabel.textContent = "";
renderAuth();
setStatus("Signed out.");
});
authBar.append(userLabel, outBtn);
} else {
const inBtn = el("button", { type: "button", className: "btn" }, ["Sign in with Microsoft"]);
inBtn.addEventListener("click", () => {
void (async () => {
try {
await signIn(msal);
renderAuth();
setStatus("Signed in.");
} catch (e) {
setStatus(e instanceof Error ? e.message : String(e));
}
})();
});
authBar.append(inBtn);
}
}
renderAuth();
renderCaseList();
const saveCaseBtn = el("button", { type: "button", className: "btn" }, ["Save case"]);
const fillFromBtn = el("button", { type: "button", className: "btn btn-secondary" }, [
"Fill from open message",
]);
saveCaseBtn.addEventListener("click", () => addOrUpdateCase());
fillFromBtn.addEventListener("click", () => void fillFromOpenMessage());
root.replaceChildren(
el("div", { className: "wrap" }, [
el("header", { className: "header" }, [
el("h1", {}, ["OL Rescue"]),
el("p", { className: "lede" }, [
"Save a case (Tracking ID + folder name), then move matching mail into an Inbox subfolder. Works in ",
el("strong", {}, ["New Outlook"]),
" via Microsoft Graph.",
]),
authBar,
]),
el("section", { className: "panel" }, [
el("h2", {}, ["Case"]),
el("label", { className: "label" }, ["Tracking ID"]),
trackingInput,
el("label", { className: "label" }, ["Inbox folder name"]),
folderInput,
el("div", { className: "btn-row" }, [saveCaseBtn, fillFromBtn]),
]),
el("section", { className: "panel" }, [
el("h2", {}, ["Saved cases"]),
caseList,
]),
el("section", { className: "panel" }, [
el("h2", {}, ["Activity"]),
statusEl,
logEl,
]),
]),
);
if (getActiveAccount(msal)) {
setStatus("Ready.");
} else {
setStatus("Sign in to use Microsoft Graph.");
}
}

80
src/auth.ts Normal file
View File

@@ -0,0 +1,80 @@
import {
InteractionRequiredAuthError,
type AccountInfo,
type IPublicClientApplication,
PublicClientApplication,
} from "@azure/msal-browser";
const GRAPH_SCOPES = [
"https://graph.microsoft.com/Mail.ReadWrite",
"https://graph.microsoft.com/User.Read",
];
export function createMsal(clientId: string): PublicClientApplication {
return new PublicClientApplication({
auth: {
clientId,
authority: "https://login.microsoftonline.com/common",
redirectUri: window.location.origin,
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: true,
},
});
}
export async function initializeAuth(msal: IPublicClientApplication): Promise<void> {
await msal.initialize();
await msal.handleRedirectPromise();
}
export async function signIn(msal: IPublicClientApplication): Promise<AccountInfo> {
await initializeAuth(msal);
const result = await msal.loginPopup({ scopes: GRAPH_SCOPES });
if (!result.account) {
throw new Error("Sign-in did not return an account.");
}
msal.setActiveAccount(result.account);
return result.account;
}
/** Call after initializeAuth when a single cached account should be used. */
export function ensureActiveAccount(msal: IPublicClientApplication): void {
if (msal.getActiveAccount()) {
return;
}
const accounts = msal.getAllAccounts();
if (accounts.length === 1) {
msal.setActiveAccount(accounts[0]);
}
}
export function signOut(msal: IPublicClientApplication): void {
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
if (account) {
void msal.logoutPopup({ account });
}
}
export async function getGraphAccessToken(msal: IPublicClientApplication): Promise<string> {
await initializeAuth(msal);
const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0];
if (!account) {
throw new Error("Not signed in.");
}
try {
const result = await msal.acquireTokenSilent({ scopes: GRAPH_SCOPES, account });
return result.accessToken;
} catch (e) {
if (e instanceof InteractionRequiredAuthError) {
const result = await msal.acquireTokenPopup({ scopes: GRAPH_SCOPES, account });
return result.accessToken;
}
throw e;
}
}
export function getActiveAccount(msal: IPublicClientApplication): AccountInfo | null {
return msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null;
}

127
src/graph.ts Normal file
View File

@@ -0,0 +1,127 @@
const GRAPH = "https://graph.microsoft.com/v1.0";
function escapeODataString(s: string): string {
return s.replace(/'/g, "''");
}
export interface GraphMessage {
id: string;
subject?: string;
}
export interface GraphMailFolder {
id: string;
displayName: string;
}
async function graphJson<T>(token: string, pathOrUrl: string, init?: RequestInit): Promise<T> {
const url = pathOrUrl.startsWith("https://") ? pathOrUrl : `${GRAPH}${pathOrUrl}`;
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph ${res.status}: ${text || res.statusText}`);
}
return res.json() as Promise<T>;
}
export async function listInboxChildFolders(token: string): Promise<GraphMailFolder[]> {
const out: GraphMailFolder[] = [];
let next: string | null =
"/me/mailFolders/inbox/childFolders?$select=id,displayName&$top=999";
while (next) {
const page = await graphJson<{ value: GraphMailFolder[]; "@odata.nextLink"?: string }>(
token,
next,
);
out.push(...page.value);
next = page["@odata.nextLink"] ?? null;
}
return out;
}
export async function findChildFolderByName(
token: string,
displayName: string,
): Promise<GraphMailFolder | undefined> {
const folders = await listInboxChildFolders(token);
return folders.find((f) => f.displayName === displayName);
}
export async function createInboxChildFolder(
token: string,
displayName: string,
): Promise<GraphMailFolder> {
return graphJson<GraphMailFolder>(token, "/me/mailFolders/inbox/childFolders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }),
});
}
export async function findOrCreateInboxChildFolder(
token: string,
displayName: string,
): Promise<GraphMailFolder> {
const existing = await findChildFolderByName(token, displayName);
if (existing) {
return existing;
}
return createInboxChildFolder(token, displayName);
}
async function listMessagesMatchingFilter(
token: string,
filter: string,
): Promise<GraphMessage[]> {
const out: GraphMessage[] = [];
let next: string | null =
`/me/messages?$filter=${encodeURIComponent(filter)}&$select=id,subject&$top=999`;
while (next) {
const page = await graphJson<{ value: GraphMessage[]; "@odata.nextLink"?: string }>(
token,
next,
);
out.push(...page.value);
next = page["@odata.nextLink"] ?? null;
}
return out;
}
/**
* Finds messages whose subject or preview contains "TrackingID#<id>".
* Falls back to subject-only filter if the tenant rejects bodyPreview in $filter.
*/
export async function findMessagesWithTrackingId(
token: string,
trackingId: string,
): Promise<GraphMessage[]> {
const needle = `TrackingID#${trackingId}`;
const q = escapeODataString(needle);
const combined = `(contains(subject,'${q}') or contains(bodyPreview,'${q}'))`;
const subjectOnly = `contains(subject,'${q}')`;
try {
return await listMessagesMatchingFilter(token, combined);
} catch {
return listMessagesMatchingFilter(token, subjectOnly);
}
}
export async function moveMessageToFolder(
token: string,
messageId: string,
destinationFolderId: string,
): Promise<void> {
await graphJson<unknown>(token, `/me/messages/${encodeURIComponent(messageId)}/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destinationId: destinationFolderId }),
});
}

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="office-js" />
import { initApp } from "./app";
Office.onReady((info) => {
const root = document.getElementById("app");
if (!root) {
return;
}
if (info.host === Office.HostType.Outlook) {
void initApp();
return;
}
root.textContent = "Open OL Rescue in Outlook (desktop or web) to use this add-in.";
});

28
src/parse.ts Normal file
View File

@@ -0,0 +1,28 @@
/** Matches internal ticket markers like TrackingID#2603260030007249 */
const TRACKING_RE = /TrackingID#(\d+)/i;
export function extractTrackingId(text: string): string | null {
const m = TRACKING_RE.exec(text);
return m ? m[1] : null;
}
/**
* Builds a suggested folder name: "<id> <short title>" from a message subject line.
*/
export function suggestFolderDisplayName(subject: string, trackingId: string): string {
let title = subject.trim();
const lower = title.toLowerCase();
const idx = lower.indexOf("trackingid#");
if (idx > 0) {
title = title.slice(0, idx).trim();
title = title.replace(/\s*-\s*$/u, "").trim();
}
title = title.replace(/^(re|fw|fwd)\s*:\s*/giu, "").trim();
title = title.replace(/^\[external\]\s*/giu, "").trim();
title = title.replace(/\s+/gu, " ").trim();
if (title.length > 80) {
title = `${title.slice(0, 77)}...`;
}
const folder = `${trackingId} ${title || "Case"}`.trim();
return folder.length > 255 ? folder.slice(0, 255) : folder;
}

35
src/storage.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface CaseEntry {
id: string;
trackingId: string;
folderDisplayName: string;
graphFolderId?: string;
}
const KEY = "ol-rescue-cases-v1";
export function loadCases(): CaseEntry[] {
try {
const raw = localStorage.getItem(KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(x): x is CaseEntry =>
typeof x === "object" &&
x !== null &&
typeof (x as CaseEntry).id === "string" &&
typeof (x as CaseEntry).trackingId === "string" &&
typeof (x as CaseEntry).folderDisplayName === "string",
);
} catch {
return [];
}
}
export function saveCases(cases: CaseEntry[]): void {
localStorage.setItem(KEY, JSON.stringify(cases));
}

221
src/styles.css Normal file
View File

@@ -0,0 +1,221 @@
:root {
color-scheme: light dark;
--bg: #0f1419;
--panel: #1a2332;
--text: #e8eef7;
--muted: #8b9cb3;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--border: #2d3a4f;
--radius: 10px;
font-family:
"Segoe UI Variable",
"Segoe UI",
system-ui,
-apple-system,
sans-serif;
font-size: 14px;
line-height: 1.45;
color: var(--text);
background: var(--bg);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6fa;
--panel: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
.wrap {
max-width: 520px;
margin: 0 auto;
padding: 16px 14px 28px;
}
.header h1 {
margin: 0 0 6px;
font-size: 1.35rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.lede {
margin: 0 0 12px;
color: var(--muted);
font-size: 0.92rem;
}
.auth-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
min-height: 36px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 14px 16px;
margin-top: 12px;
}
.panel h2 {
margin: 0 0 10px;
font-size: 0.85rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.label {
display: block;
margin-top: 10px;
margin-bottom: 4px;
font-size: 0.82rem;
color: var(--muted);
}
.input {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font: inherit;
}
.input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.btn {
appearance: none;
border: none;
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: #fff;
}
.btn:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
color: var(--text);
border-color: var(--muted);
}
.btn-small {
padding: 6px 10px;
font-size: 0.85rem;
}
.status {
font-size: 0.9rem;
margin-bottom: 8px;
min-height: 1.2em;
}
.log {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: var(--bg);
border: 1px solid var(--border);
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
}
.muted {
color: var(--muted);
font-size: 0.9rem;
}
.case-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.case-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
}
.case-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.case-meta strong {
font-weight: 650;
font-size: 0.95rem;
}
.case-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
code {
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 0.88em;
padding: 1px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--muted) 18%, transparent);
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_AZURE_CLIENT_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM"],
"types": ["vite/client", "office-js"]
},
"include": ["src/**/*.ts"]
}

15
vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import basicSsl from "@vitejs/plugin-basic-ssl";
export default defineConfig({
plugins: [basicSsl()],
server: {
port: 3000,
strictPort: true,
https: {},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});