Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 85x 85x 85x 72x 71x 26x 26x 1x 41x 41x 41x 41x 41x 41x 41x 37x 37x 37x 1x 48x 48x 48x 48x 3x 3x 3x 3x 3x 3x 3x 48x 48x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 1x 3x 3x 3x 3x 3x 1x 3x 3x 3x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | import { canonicalKey } from "../lib/canonicalUrl";
import type { Arrangement, JobStatus, SavedJob } from "../types/job";
import { ApiError, apiFetch } from "./api/apiClient";
/**
* The library hit its tier cap (100 free / 1,000 premium — data-model.md);
* the server names the exact cap and the tier-appropriate action (upgrade
* vs. prune/export) in `message`, so the UI never hardcodes a number.
*/
export class LibraryFullError extends Error {}
export interface JobListFilter {
arrangement?: Arrangement;
status?: JobStatus;
}
/**
* Saved-jobs repository. Same interface as the original chrome.storage.local
* implementation — since 002 it is backed by the per-account server store
* (contracts/storage-api.md), so the library follows the signed-in user
* across devices. UI code above this interface is unchanged.
*/
export interface JobRepository {
get(canonicalUrl: string): Promise<SavedJob | null>;
list(filter?: JobListFilter): Promise<SavedJob[]>;
save(job: SavedJob): Promise<void>;
update(canonicalUrl: string, patch: Partial<SavedJob>): Promise<void>;
remove(canonicalUrl: string): Promise<void>;
exportAll(): Promise<string>;
pruneArchived(count: number): Promise<number>;
}
async function throwUnexpected(response: Response): Promise<never> {
let message = "The storage service rejected the request.";
try {
const body = (await response.json()) as { error?: { message?: string } };
if (body.error?.message) message = body.error.message;
} catch {
// Keep the generic message.
}
throw new ApiError(response.status, "SERVICE_ERROR", message, false);
}
async function get(canonicalUrl: string): Promise<SavedJob | null> {
const key = await canonicalKey(canonicalUrl);
const response = await apiFetch(`/jobs/${key}`);
if (response.status === 404) return null;
if (!response.ok) await throwUnexpected(response);
return (await response.json()) as SavedJob;
}
async function list(filter?: JobListFilter): Promise<SavedJob[]> {
const params = new URLSearchParams();
if (filter?.arrangement) params.set("arrangement", filter.arrangement);
if (filter?.status) params.set("status", filter.status);
const query = params.toString();
const response = await apiFetch(`/jobs${query ? `?${query}` : ""}`);
if (!response.ok) await throwUnexpected(response);
const body = (await response.json()) as { jobs: SavedJob[] };
return body.jobs;
}
async function save(job: SavedJob): Promise<void> {
const key = await canonicalKey(job.canonicalUrl);
const response = await apiFetch(`/jobs/${key}`, { method: "PUT", body: job });
if (response.status === 409) {
let message = "Your library is full. Export it or remove a posting to save this one.";
try {
const body = (await response.json()) as { error?: { message?: string } };
if (body.error?.message) message = body.error.message;
} catch {
// Keep the generic message.
}
throw new LibraryFullError(message);
}
if (!response.ok) await throwUnexpected(response);
}
async function update(
canonicalUrl: string,
patch: Partial<SavedJob>
): Promise<void> {
const key = await canonicalKey(canonicalUrl);
const response = await apiFetch(`/jobs/${key}`, {
method: "PATCH",
body: {
...(patch.status !== undefined ? { status: patch.status } : {}),
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
...(patch.analysis !== undefined ? { analysis: patch.analysis } : {}),
},
});
// Matches the previous local semantics: updating a missing record is a no-op.
if (response.status === 404) return;
if (!response.ok) await throwUnexpected(response);
}
async function remove(canonicalUrl: string): Promise<void> {
const key = await canonicalKey(canonicalUrl);
const response = await apiFetch(`/jobs/${key}`, { method: "DELETE" });
if (!response.ok && response.status !== 204) await throwUnexpected(response);
}
async function exportAll(): Promise<string> {
const response = await apiFetch("/jobs/export");
if (!response.ok) await throwUnexpected(response);
// Raw text: the server emits the byte-exact legacy export format (FR-009).
return response.text();
}
async function pruneArchived(count: number): Promise<number> {
const response = await apiFetch("/jobs/prune", {
method: "POST",
body: { count },
});
if (!response.ok) await throwUnexpected(response);
const body = (await response.json()) as { pruned: number };
return body.pruned;
}
export const jobStorage: JobRepository = {
get,
list,
save,
update,
remove,
exportAll,
pruneArchived,
};
|