Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ external reader's orientation to the whole fork, see
reusable, permission-triggered contextual consent toast (`useConsentToast`
- `ConsentToast.tsx`), issue #204. No dependency on any consumer feature
yet; issue #203 (client-side phash contribution) is the first planned one.
- [`docs/features/foreign-order-resilience.md`](docs/features/foreign-order-resilience.md)
— issue #324 Phase 1 (shipped 2026-07-23): rendering "orphan" cards
(Drive file IDs the catalog has never indexed) from text (`[mpc:<id>]`
token) and XML import, direct-from-Google image fetch (never our own
CDN), the invalidation-listener root-cause fix, round-trip export, and
what's deferred to Phase 2.
- [`docs/upstreaming/vote-system.md`](docs/upstreaming/vote-system.md) —
cherry-pick extraction manifest for the vote system (companion to the
Upstreaming workflow in `docs/infrastructure.md`); accurate through
Expand Down
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ The methodology and the systems it governs.
bottom-corner accept/decline prompt shown only right before an action
that needs it, per-permission-key session scoping, no dependency on any
consumer feature yet.
- [`features/foreign-order-resilience.md`](features/foreign-order-resilience.md)
— issue #324 Phase 1: rendering "orphan" cards (Drive file IDs the
catalog has never indexed) from text/XML import, direct-from-Google
fetch with tiered sizing, the invalidation-listener root-cause fix,
round-trip export, and what's deferred to Phase 2.

## Using it

Expand Down
287 changes: 287 additions & 0 deletions docs/features/foreign-order-resilience.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/upstreaming/extractable-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ coupling to the vote system is.
| Image-CDN URL helpers | `frontend/src/common/image.ts` | Resolves a card identifier to its bucket/worker CDN URL | upstream, proxies-at-home (with their own CDN) | entangled-with-image-cdn-infra | — |
| Contextual consent toast | `frontend/src/features/consent/consentToast.ts`, `ConsentToast.tsx`, `useConsentToast.tsx` | General-purpose permission-triggered accept/decline toast (`useConsentToast().requestConsent(key, message)`), per-permission-key session-scoped decision, no consumer feature wired in yet (issue #204) | upstream, proxies-at-home | CLEAN | — |
| Sliced-wordmark pop animation (`WhatsThatWords`) | `frontend/src/features/questionFeed/WhatsThatWords.tsx` | Inlines an SVG wordmark's path data once and crops it into N independently-animated bands via per-instance `viewBox`, staggered CSS keyframe pop + `prefers-reduced-motion` fallback — the technique (not the specific WHAT'S/THAT/CARD? content) generalizes to any multi-word SVG lockup | upstream, proxies-at-home | CLEAN | — |
| Orphan-card synthesis (foreign-order resilience) | `frontend/src/common/orphanCard.ts` | Synthesizes a minimal, renderable `CardDocument` for an image identifier the catalog hasn't indexed (Drive-ID allowlist regex, direct-from-source thumbnail/full-res URL builder, untrusted-name sanitizer) — no catalog/consensus lookup, no vote system, no auth (issue #324 Phase 1) | upstream, proxies-at-home (with their own CDN) | CLEAN | — |

## Frontend — PDF / export

Expand Down
Binary file added frontend/src/common/orphanCard.test.ts
Binary file not shown.
180 changes: 180 additions & 0 deletions frontend/src/common/orphanCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Foreign-order resilience, Phase 1 (issue #324): rendering support for project slots whose
* selected image is a Google Drive file ID this catalog has never indexed - e.g. a text or XML
* import built against another mpc-autofill instance. An "orphan" card is addressed purely by
* its Drive file ID and is never routed through our own image-CDN Worker or R2 bucket (that
* cache stays catalog-only, per the issue's own Phase 1 bullet) - it's fetched direct from
* Google's own image-serving domain, at the SAME two size tiers (400px/800px "height" params)
* our own Worker already uses for its small/large thumbnail tiers (see
* image-cdn/src/service/GoogleDriveService.ts's getLH4Params/getImageURL and
* image-cdn/src/types.ts's ImageSizes) - same visual-size discipline the owner's 2026-07-22
* resolution-tiering ruling requires, just built from this module instead of going through the
* Worker. See docs/features/foreign-order-resilience.md for the full design.
*/

import { CardType, PrintingTagStatus } from "@/common/schema_types";
import { CardDocument } from "@/common/types";

/**
* Owner-ratified allowlist (2026-07-22 security review comment on issue #324): validated before
* ANY URL is built from an identifier that didn't come from our own indexing pipeline. A
* rejection here is treated as a genuinely invalid identifier - it's never used to construct a
* fetch, regardless of how plausible it looks otherwise.
*/
export const DriveFileIdPattern = /^[A-Za-z0-9_-]{10,200}$/;

export const isLikelyDriveFileId = (identifier: string): boolean =>
DriveFileIdPattern.test(identifier);

/** Mirrors image-cdn/src/types.ts's ImageSizes exactly - the small/large thumbnail tiers an
* orphan's editor-grid rendering must match in size discipline (owner ruling, 2026-07-22). */
const OrphanImageHeightPx = { small: 400, large: 800 } as const;

const DirectGoogleImageOrigin = "https://lh4.googleusercontent.com";

/**
* Build a direct-from-Google image URL for an orphan identifier - NEVER routed through our
* image-CDN Worker or R2 bucket (see module doc). `height` mirrors the Worker's own `=h<px>`
* URL suffix (GoogleDriveService.getImageURL) for the small/large tiers; omitting it requests
* the original, unresized file - the "full" tier, used only for PDF export, never the editor
* grid (owner ruling: "the editor grid never requests it").
*
* The identifier is validated against `DriveFileIdPattern` before it ever reaches URL
* construction, and is placed in the URL via the `URL` constructor (not string
* interpolation) - only the size suffix, which is always one of two code-fixed values, is
* appended as a literal.
*/
export const buildOrphanImageURL = (
identifier: string,
height: number | undefined
): string | undefined => {
if (!isLikelyDriveFileId(identifier)) {
return undefined;
}
const base = new URL(`/d/${identifier}`, DirectGoogleImageOrigin).toString();
return height !== undefined ? `${base}=h${height}` : base;
};

/** The editor grid / preview tile tier - never used for PDF export. */
export const getOrphanSmallImageURL = (
identifier: string
): string | undefined =>
buildOrphanImageURL(identifier, OrphanImageHeightPx.small);

/** Unused today (no orphan-specific "large" surface yet), kept for parity with the catalog
* path's own small/large/full tier triad. */
export const getOrphanLargeImageURL = (
identifier: string
): string | undefined =>
buildOrphanImageURL(identifier, OrphanImageHeightPx.large);

/** The PDF-export tier - original resolution, fetched only on an explicit export action, never
* speculatively (owner ruling). */
export const getOrphanFullResolutionImageURL = (
identifier: string
): string | undefined => buildOrphanImageURL(identifier, undefined);

// Built from character codes rather than a literal escape sequence in this source
// file, to avoid embedding raw control bytes in the repo - equivalent to
// /[\x00-\x1F\x7F]/g.
const ControlCharPattern = new RegExp(
`[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(
127
)}]`,
"g"
);
const StandInNameMaxLength = 120;

/**
* The stand-in name Phase 1 shows for an orphan (its slot's own XML/text search query) is
* untrusted input from a file the user uploaded, not our own indexing pipeline - stripped of
* control characters and length-capped before it reaches any display or `data-card-*` sink
* (owner ruling, 2026-07-22 security review). React's JSX text nodes and `getCardDataAttributes`'s
* DOM-API attribute assignment are already immune to injection either way (see
* docs/features/card-dom-api.md), but the cap/strip still applies as defence in depth and to
* keep a maliciously huge query string from bloating the DOM.
*/
export const sanitizeStandInName = (rawName: string): string => {
const stripped = rawName.replace(ControlCharPattern, "").trim();
return stripped.length > StandInNameMaxLength
? `${stripped.slice(0, StandInNameMaxLength)}…`
: stripped;
};

/** Shown when an orphan's originating slot carried no usable query text (e.g. the reported
* `b:null` back-face case - the foreign XML's own `<query>` element was empty). */
export const OrphanFallbackName = "Unindexed card";

/**
* Synthesize a minimal CardDocument for an identifier the catalog has never indexed - Phase 1's
* "on identifier lookup miss" step. Only ever called for identifiers that already passed
* `isLikelyDriveFileId` (see cardDocumentsSlice.ts's fetchCardDocuments thunk, the sole caller).
* Deliberately never sets `sourceType` - leaving it `undefined` is what keeps
* `common/image.ts`'s bucket/Worker URL builders (which gate on
* `sourceType === SourceType.GoogleDrive`) and `pdfImage.ts`'s source-type switch from ever
* routing an orphan through our own CDN; `isOrphan: true` is the one field every consumer that
* needs to special-case an orphan actually checks.
*/
export const synthesizeOrphanCardDocument = (
identifier: string,
standInQuery?: { name: string | null; cardType: CardType } | undefined
): CardDocument => {
const rawName = standInQuery?.name;
const sanitizedName =
rawName != null && rawName.length > 0
? sanitizeStandInName(rawName)
: undefined;
return {
cardType: standInQuery?.cardType ?? CardType.Card,
dateCreated: "",
dateModified: "",
dpi: 0,
extension: "",
identifier,
isOrphan: true,
language: "EN",
mediumThumbnailUrl: getOrphanLargeImageURL(identifier),
name: sanitizedName ?? OrphanFallbackName,
printingTagStatus: PrintingTagStatus.NoMatch,
priority: 0,
// Deliberately NOT the sanitized display name - this is the round-trip field
// downloadXML.ts's createCardElement reads to rebuild the `<query>` element on re-export
// (see docs/features/foreign-order-resilience.md's round-trip section). Falling back to the
// fabricated OrphanFallbackName here would corrupt a re-exported file with text that was
// never actually the user's search query.
searchq: sanitizedName ?? "",
size: 0,
smallThumbnailUrl: getOrphanSmallImageURL(identifier),
source: "",
sourceId: -1,
sourceName: "Your file",
sourceVerbose: "Your file",
tags: [],
};
};

/**
* Build orphan CardDocuments for every identifier in `identifiers` that looks like a real Drive
* file ID - anything else is left out entirely (genuinely invalid, not an orphan candidate).
* `standInQueryByIdentifier` supplies each identifier's own project-member query text/cardType
* when known (see cardDocumentsSlice.ts), so the synthesized name and the XML round-trip's
* `searchq` field reflect what the user actually asked for, not a generic placeholder.
*/
export const buildOrphanCardDocuments = (
identifiers: Array<string>,
standInQueryByIdentifier: Map<
string,
{ name: string | null; cardType: CardType }
> = new Map()
): { [identifier: string]: CardDocument } =>
Object.fromEntries(
identifiers
.filter(isLikelyDriveFileId)
.map((identifier) => [
identifier,
synthesizeOrphanCardDocument(
identifier,
standInQueryByIdentifier.get(identifier)
),
])
);
77 changes: 77 additions & 0 deletions frontend/src/common/processing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Token,
} from "@/common/constants";
import {
extractDriveIdBracketToken,
parseCSVFileAsLines,
processLine,
processQuery,
Expand Down Expand Up @@ -470,6 +471,82 @@ test("a line specifying the selected image ID for both faces is processed correc
]);
});

describe("foreign-order resilience Phase 1 - [mpc:<id>] bracket token (issue #324)", () => {
const driveId = "1FItgPw7VK_Tbv6dMiqdy5zd-jAoEC9mn";

test("extractDriveIdBracketToken strips a valid token and returns the ID", () => {
expect(extractDriveIdBracketToken(`Kharn [mpc:${driveId}]`)).toEqual([
"Kharn",
driveId,
]);
});

test("extractDriveIdBracketToken leaves text unchanged when there's no token", () => {
expect(extractDriveIdBracketToken("Kharn")).toEqual(["Kharn", undefined]);
});

test("extractDriveIdBracketToken leaves text unchanged when the bracketed contents don't look like a real Drive file ID", () => {
expect(extractDriveIdBracketToken("Kharn [mpc:too-short]")).toEqual([
"Kharn [mpc:too-short]",
undefined,
]);
});

test("the owner's exact reported repro line registers a selected image", () => {
// Symptom (a) from issue #324's high-priority promotion comment: this exact line was
// reported as not registering at all.
expect(processLine(`1x Kharn [mpc:${driveId}]`, dfcPairs, false)).toEqual([
1,
{
query: { cardType: Card, query: "kharn" },
selectedImage: driveId,
selected: false,
},
null,
]);
});

test("a bracket token on the back face is processed correctly", () => {
expect(
processLine(
`2x front card${FaceSeparator}back card [mpc:${driveId}]`,
dfcPairs,
false
)
).toEqual([
2,
{
query: { cardType: Card, query: "front card" },
selectedImage: undefined,
selected: false,
},
{
query: { cardType: Card, query: "back card" },
selectedImage: driveId,
selected: false,
},
]);
});

test("a bracket token takes precedence over a trailing @id for the same face", () => {
expect(
processLine(
`opt${SelectedImageSeparator}legacyid [mpc:${driveId}]`,
dfcPairs,
false
)
).toEqual([
1,
{
query: { cardType: Card, query: "opt" },
selectedImage: driveId,
selected: false,
},
null,
]);
});
});

describe("file path-like identifier handling", () => {
test.each([
{
Expand Down
57 changes: 53 additions & 4 deletions frontend/src/common/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SelectedImageSeparator,
Token,
} from "@/common/constants";
import { isLikelyDriveFileId } from "@/common/orphanCard";
import {
CardDocument,
CSVRow,
Expand Down Expand Up @@ -141,6 +142,41 @@ const extractQuantity = (line: string): [number, string] => {
return [quantity, results[2]];
};

// Foreign-order resilience Phase 1 (issue #324) - a bracketed `[mpc:<id>]` token, e.g.
// `1x Kharn [mpc:1FItgPw7VK_Tbv6dMiqdy5zd-jAoEC9mn]`, is an ADDITIONAL way to pin a line's
// selected image alongside the existing `query@id` (SelectedImageSeparator) syntax above - not
// a replacement for it. Distinct from `@id` in one deliberate way: `@id` has always been read as
// "this identifier IS a catalog image" (the search-results-driven listener in
// listenerMiddleware.ts clears it if the catalog doesn't back it up), whereas `[mpc:id]` is the
// explicit "this identifier is a Drive file ID, indexed or not" declaration - useful for pasting
// a decklist that names cards by their exact Drive file, from an order built against another
// mpc-autofill instance. Once extracted, both forms flow into the exact same
// `ProjectMember.selectedImage` field, so every downstream orphan-rendering fix (the
// cardDocumentsSlice synthesis + listenerMiddleware invalidation-skip) applies identically
// regardless of which syntax supplied it.
const DriveIdBracketTokenRegex = /\[mpc:([^\]]+)\]/i;

/**
* Strip a `[mpc:<id>]` token from `text` if present and its captured ID passes
* `isLikelyDriveFileId` - returns the token-stripped text plus the extracted ID (or the
* original text unchanged and `undefined` if there's no token, or its contents don't look like
* a real Drive file ID - in that case it's left in place as ordinary query text rather than
* silently eaten).
*/
export const extractDriveIdBracketToken = (
text: string
): [string, string | undefined] => {
const match = text.match(DriveIdBracketTokenRegex);
if (match == null) {
return [text, undefined];
}
const candidateId = match[1].trim();
if (!isLikelyDriveFileId(candidateId)) {
return [text, undefined];
}
return [text.replace(match[0], "").trim(), candidateId];
};

/**
* Unpack `line` into its constituents.
*
Expand All @@ -150,14 +186,21 @@ const extractQuantity = (line: string): [number, string] => {
*
* If quantity is not specified, we assume a quantity of 1.
* Specifying a back query is optional.
* Specifying an image ID (for each face) is optional.
* Specifying an image ID (for each face) is optional. A `[mpc:<id>]` token anywhere in either
* face's text (see `extractDriveIdBracketToken`) is another way to specify the image ID, and
* takes precedence over a trailing `@id` for that same face if somehow both are present.
*/
function unpackLine(
line: string
): [number, [string, string | null] | null, [string, string | null] | null] {
const [quantity, trimmedLine] = extractQuantity(line);

const [frontLine, backLine] = trimmedLine.split(FaceSeparator);
const [rawFrontLine, rawBackLine] = trimmedLine.split(FaceSeparator);
const [frontLine, frontBracketId] = extractDriveIdBracketToken(rawFrontLine);
const [backLine, backBracketId] =
rawBackLine !== undefined
? extractDriveIdBracketToken(rawBackLine)
: [undefined, undefined];

const faceLineRegex = new RegExp(
`^(.+?)(?:${SelectedImageSeparator}(${getPhrasesNotAllowedInIdentifiersNegativeLookahead()}))?$`,
Expand All @@ -172,9 +215,15 @@ function unpackLine(
}
return [
quantity,
[frontLineResults[1]?.trim(), frontLineResults[2]?.trim()],
[
frontLineResults[1]?.trim(),
frontBracketId ?? frontLineResults[2]?.trim(),
],
backLineResults !== null
? [backLineResults[1]?.trim(), backLineResults[2]?.trim()]
? [
backLineResults[1]?.trim(),
backBracketId ?? backLineResults[2]?.trim(),
]
: null,
];
}
Expand Down
Loading
Loading