Feature 15 F15-01: always credit a grounded recipe's source #163
@@ -60,11 +60,12 @@ type Ingredient struct {
|
|||||||
|
|
||||||
// Source is the site a recipe was fetched from. SiteName is the name the
|
// Source is the site a recipe was fetched from. SiteName is the name the
|
||||||
// page itself gives its site, and null when it gives none or the stored
|
// page itself gives its site, and null when it gives none or the stored
|
||||||
// name could have come from a link (see pageSiteName), so a client shows
|
// name could have come from a link (see pageSiteName). URL is the page's
|
||||||
// "Recipe from {site}" only when SiteName is not null. URL is the page's
|
|
||||||
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
||||||
// null otherwise: a link pasted or offered as a candidate may carry a
|
// null otherwise: a link pasted or offered as a candidate may carry a
|
||||||
// personal token, so a client links the credit only when URL is not null.
|
// personal token, so a client links the credit only when URL is not null.
|
||||||
|
// Source is set for every fetched recipe, so a client always credits it:
|
||||||
|
// by SiteName, else by URL's host, else without naming the site.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
SiteName *string `json:"site_name"`
|
SiteName *string `json:"site_name"`
|
||||||
URL *string `json:"url"`
|
URL *string `json:"url"`
|
||||||
@@ -189,8 +190,8 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
|||||||
// stored. Nothing records which a stored name is, so any name that could
|
// stored. Nothing records which a stored name is, so any name that could
|
||||||
// be a host — the link's own host, an IP address, or a dotted host name —
|
// be a host — the link's own host, an IP address, or a dotted host name —
|
||||||
// is treated as one and withheld. A site that names itself as a domain,
|
// is treated as one and withheld. A site that names itself as a domain,
|
||||||
// such as "Food.com", loses its attribution with them: a recipe with no
|
// such as "Food.com", loses its name with them: a credit that does not
|
||||||
// attribution is better than one that leaks a link.
|
// name the site is better than one that leaks a link.
|
||||||
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
||||||
if !stored.Valid {
|
if !stored.Valid {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -204,12 +204,25 @@ describe('DinnerDetailPage', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('leaves the credit out when the server names no site', async () => {
|
it("links the credit by the url's host when the server names no site", async () => {
|
||||||
serve(detail({ source: { site_name: null } }))
|
serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } }))
|
||||||
renderAt(['/dinner/d1'])
|
renderAt(['/dinner/d1'])
|
||||||
|
|
||||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' })
|
||||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls')
|
||||||
|
expect(credit).toHaveAttribute('target', '_blank')
|
||||||
|
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The server withholds both when the stored name could be the link's
|
||||||
|
// host and the link could carry a token: the credit names neither.
|
||||||
|
it('credits the web in plain text when the server sends neither site nor url', async () => {
|
||||||
|
serve(detail({ source: { site_name: null, url: null } }))
|
||||||
|
renderAt(['/dinner/d1'])
|
||||||
|
|
||||||
|
const credit = await screen.findByText('Recipe from the web')
|
||||||
|
expect(credit.closest('a')).toBeNull()
|
||||||
|
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('holds Add to this week and the heart at the bottom', async () => {
|
it('holds Add to this week and the heart at the bottom', async () => {
|
||||||
|
|||||||
@@ -67,10 +67,14 @@ function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceCredit is "Recipe from {site}": a link opening the recipe's page in
|
// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a
|
||||||
// a new tab when the server sends a url safe to open, else plain text.
|
// link opening the recipe's page in a new tab when the server sends a url
|
||||||
function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
|
// safe to open, else plain text. The site is the name the page gave, else
|
||||||
const credit = `Recipe from ${siteName}`
|
// the url's host — already in the link, so naming it shows nothing more —
|
||||||
|
// else, with neither to show, just "the web": a fetched recipe always
|
||||||
|
// credits where it came from, without a name the server withheld.
|
||||||
|
function SourceCredit({ siteName, url }: RecipeSource) {
|
||||||
|
const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}`
|
||||||
return (
|
return (
|
||||||
<p className={styles.source}>
|
<p className={styles.source}>
|
||||||
{url === null ? (
|
{url === null ? (
|
||||||
@@ -136,9 +140,7 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{dinner.source !== null && dinner.source.siteName !== null && (
|
{dinner.source !== null && <SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />}
|
||||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -506,7 +508,7 @@ function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
{!recipe && <RecipeMissing dinner={dinner} />}
|
{!recipe && <RecipeMissing dinner={dinner} />}
|
||||||
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
||||||
{recipe && dinner.source !== null && dinner.source.siteName !== null && (
|
{recipe && dinner.source !== null && (
|
||||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||||
)}
|
)}
|
||||||
<ActionBar dinner={dinner}>
|
<ActionBar dinner={dinner}>
|
||||||
|
|||||||
@@ -410,6 +410,36 @@ describe('Kitchen reading mode', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('source credit', () => {
|
||||||
|
it.each([
|
||||||
|
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
|
||||||
|
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
|
||||||
|
])('links the recipe page in a new tab for %s', async (_, source, name) => {
|
||||||
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||||
|
await renderPage()
|
||||||
|
const credit = screen.getByRole('link', { name })
|
||||||
|
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
|
||||||
|
expect(credit).toHaveAttribute('target', '_blank')
|
||||||
|
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
|
||||||
|
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
|
||||||
|
])('is plain text for %s', async (_, source, text) => {
|
||||||
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByText(text).closest('a')).toBeNull()
|
||||||
|
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is absent for a recipe typed in', async () => {
|
||||||
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
||||||
serve({
|
serve({
|
||||||
[DINNER]: () =>
|
[DINNER]: () =>
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ export type Ingredient = {
|
|||||||
// RecipeSource is where a fetched recipe came from. siteName is the site,
|
// RecipeSource is where a fetched recipe came from. siteName is the site,
|
||||||
// or null when there is none to name: a page that named no site, or a name
|
// or null when there is none to name: a page that named no site, or a name
|
||||||
// the server withheld because it could have come from the link. url is the
|
// the server withheld because it could have come from the link. url is the
|
||||||
// recipe's page, or null: today's API never sends the stored link, which can
|
// recipe's page, or null when the server withheld the stored link because
|
||||||
// carry a token, so the credit is text until the server sends a url it has
|
// it could carry a token; the credit then never names its host.
|
||||||
// checked is free of secrets.
|
|
||||||
export type RecipeSource = { siteName: string | null; url: string | null }
|
export type RecipeSource = { siteName: string | null; url: string | null }
|
||||||
|
|
||||||
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
|
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
|
||||||
@@ -93,7 +92,7 @@ function detailPhotoId(where: string, value: unknown): string | undefined {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceUrl is source.url, which today's API does not send, or a link safe
|
// sourceUrl is source.url, null when the server withholds it, or a link safe
|
||||||
// to open: https, with no user, password, query or fragment.
|
// to open: https, with no user, password, query or fragment.
|
||||||
function sourceUrl(where: string, value: unknown): string | null {
|
function sourceUrl(where: string, value: unknown): string | null {
|
||||||
if (value === undefined || value === null) return null
|
if (value === undefined || value === null) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user