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
26 changes: 26 additions & 0 deletions docs/architecture/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,32 @@ siblings are `VutuvWeb.AgentDocs.PressKitDoc`, and the profile document carries
the same entries under `press_kit`; both list the **released** pictures only,
since a document that names a file must name one that can be fetched.

**A page has the same section, kept by its team (issue #2087).** The card, the
section page, the documents, the schema.org block and the sitemap entry are the
same code with an organization as the owner — `PressKitComponents.press_card/1`
(lifted out of the profile template when the page needed the second copy),
`VutuvWeb.PressKitController.organization/2`, `PressKitDoc.build/2`. Three things
are the page's own. **The URL is built from the slug**, `/organizations/:slug/press`
(`PressKit.page_path/1`), not from a claimed root handle: that handle dispatches
the bare `/:slug` alone, so `Identity.path/1 <> "/press"` answered an address that
404ed. The **editor** is `/organizations/:slug/press/edit` — the same
`VutuvWeb.PressKitLive`, embedded by `OrganizationController.press/2` instead of
routed, so it resolves the viewer with `InitAssigns.assign_embedded/2` and
re-asks `manageable_by?/2` on the socket (`OrganizationLive.ManageGate`) rather
than trusting the signed `organization_id`; it is on the manage tab bar and in
the page's own manage strip, for an owner or a publisher. And a page may take
**its own logo** onto the logo shelf with one press (`PressKit.adopt_page_logo/3`)
where that logo is a **vector** — `File.cp!` through the ordinary `create/4`, so
the page's original stays where it is and the copy meets the same cap, whitelist,
rights stamp and scan; a raster logo is not offered, since the file the page
uploaded is already the screen-sized copy and would be a print promise it cannot
keep.

The sentences whose German addresses the reader as the owner ("Ihr Logo", "über
Sie schreibt") get **their own msgids** for the page's editor rather than sharing
the member's: `gettext.extract --merge` fuzzy-filled all three with exactly that
member-voiced German, which is the trap `docs/architecture/i18n.md` records.

### The takedown hold (issue #2012)

A copyright freeze **moves** a picture, it never deletes one, and the tree it
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture/organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,19 @@ column `user_id` empty for this kind, because it cascades. The whole move,
including what the release that wires the takedown has to answer for a page
that cannot be struck, is in [images.md](images.md).

Beside the logo a page keeps a **press kit** (#2087): press photos and logo
variants a journalist downloads, on a card below the open positions and on
`/organizations/:slug/press`. Its editor is `/organizations/:slug/press/edit`,
on the manage tab bar, and it is the one manage page a **publisher** reaches
without holding an administrative role — the material a page hands the press is
part of speaking for it (`Vutuv.PressKit.manageable_by?/2`, owners and
publishers, deliberately not `can_manage?/2`, which also counts whoever claimed
the page). The rows belong to the page: `organization_id` owns them and the
colleague who uploaded one only rides in `uploader_user_id`, so a press photo
outlives their account exactly as the logo does. Where the page's own logo is a
vector it is one press away from being the first variant. The whole kind is in
[images.md](images.md).

## The homepage screenshot

The page shows a picture of the website it names — a "Website" card at the top
Expand Down
138 changes: 128 additions & 10 deletions lib/vutuv/press_kit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ defmodule Vutuv.PressKit do
alias Vutuv.Moderation.ImageScans
alias Vutuv.Moderation.Pixelation
alias Vutuv.Ordering
alias Vutuv.OrganizationImageStore
alias Vutuv.Organizations
alias Vutuv.Organizations.Organization
alias Vutuv.PressKitStore
Expand Down Expand Up @@ -117,6 +118,21 @@ defmodule Vutuv.PressKit do
@doc "The owner's logo variants, in the order they are shown."
def logos(owner), do: shelf_rows(owner, true)

@doc """
Both shelves of one owner's kit **whatever their moderation state** —
`%{photos:, logos:}`, each in the owner's own order. What the editor lists,
which is the one surface that shows a picture the AI gate is still holding as
itself rather than as a stand-in.

One query for both, for `public_shelves/2`'s reason: the caps are ten and five,
so the whole kit is at most fifteen rows and the split is cheaper in memory
than a second round trip — and the editor reloads them after every save,
delete, move, reorder and upload.
"""
def shelves(owner) do
owner |> owned() |> Ordering.by_position() |> Repo.all() |> split()
end

@doc "How many pictures the owner has on that shelf."
def count(owner, logo?) when is_boolean(logo?),
do: owner |> shelf(logo?) |> Repo.aggregate(:count)
Expand Down Expand Up @@ -254,13 +270,22 @@ defmodule Vutuv.PressKit do
"""
def manageable_by?(%User{id: id}, %User{id: id}), do: true

def manageable_by?(%Organization{} = organization, %User{} = viewer) do
powers = Organizations.role_powers(organization, viewer)
powers.owner? or powers.publisher?
end
def manageable_by?(%Organization{} = organization, %User{} = viewer),
do: organization |> Organizations.role_powers(viewer) |> manageable_by_powers?()

def manageable_by?(_owner, _viewer), do: false

@doc """
The same rule, read off a `Vutuv.Organizations.role_powers/2` answer somebody
already holds — for the page itself, which consolidated five permission reads
into that one and must not grow a sixth to ask this.

Public so the rule keeps one home: `manageable_by?/2` is this function plus
the read.
"""
def manageable_by_powers?(%{owner?: owner?, publisher?: publisher?}),
do: owner? or publisher?

@doc """
Whether `viewer` may fetch this picture's bytes — the one statement of that
rule, which the proxy and every surface #2086 and #2087 build ask.
Expand Down Expand Up @@ -430,17 +455,36 @@ defmodule Vutuv.PressKit do
defp address(%Image{token: token}, file), do: "/system/press_kit/#{token}/#{file}"

@doc """
Where this owner's press kit is published: `/ada.king/press` for a member, the
page's canonical path plus `/press` for a page — `Vutuv.Identity.path/1` plus
one segment, so the next owner kind is remembered in the one place that
already knows about owner kinds.
Where this owner's press kit is published: `/ada.king/press` for a member,
`/organizations/acme/press` for a page.

For everything holding an owner rather than a route: the doc's canonical URL,
the schema.org block, and #2087's page twin. The profile template keeps its
verified `~p` sigil, which is a compile-time check this cannot give it.
the schema.org block, the card's footer link and the sitemap entry.

**A page is addressed by its slug here, not by its root handle**, which is
where this deviates from `Vutuv.Identity.path/1`. A page that claimed a root
handle is canonical at `/acme`, but that handle dispatches the **bare**
`/:slug` page alone (`VutuvWeb.Plug.UserResolveSlug`, `dispatch_organization:
true` on that one route) — every sub-page of a page lives under
`/organizations/:slug/`, exactly as its post permalinks do, and #2086 shipped
`Identity.path/1 <> "/press"` here, which answered `/acme/press` for such a
page and 404ed.
"""
def page_path(%Organization{slug: slug}), do: "/organizations/#{slug}/press"
def page_path(owner), do: Identity.path(owner) <> "/press"

@doc """
Where this kit is **edited**: `/settings/press` for a member (#2085), the
page's own editor for a page (#2087).

The second path this kind owns, and it needs one owner for the same reason the
first does — four surfaces link to it (the section page's "Manage" bridge, the
card's add tile, the page's manage menu and the editor's own trail), and a
member's editor carries no slug at all while a page's does.
"""
def editor_path(%Organization{slug: slug}), do: "/organizations/#{slug}/press/edit"
def editor_path(%User{}), do: "/settings/press"

@doc """
The one sentence under which every press picture is offered.

Expand Down Expand Up @@ -519,6 +563,57 @@ defmodule Vutuv.PressKit do
end
end

@doc """
The page's own logo file, when it is one the logo shelf could take as a
variant: the absolute path of the stored original, or `nil` (#2087).

**A vector only, deliberately.** A press logo is offered for print, and what a
page uploaded as a raster is already the screen-sized copy
`Vutuv.OrganizationImageStore` derived from it — handing a journalist a 512 px
PNG under the word "logo" is worse than handing them nothing, and it is what
the page's own tile already shows. A vector is the one case where the file the
page uploaded **is** the printable one, which is why #2082 asked for the
one-click offer on exactly that case.

It asks the disk rather than the row's `content_type`, because the disk is
what `adopt_page_logo/3` will copy: an installation restored from a snapshot
carries no `originals/` tree, and a button offering a file that is not there
would fail on the press instead of never appearing.
"""
def page_logo_source(%Organization{logo: token}) when is_binary(token) do
path = OrganizationImageStore.original_path(token)

if path && Path.extname(path) == ".svg", do: path
end

def page_logo_source(_owner), do: nil

@doc """
Copies that logo onto the page's logo shelf as one more variant — the
one-click adoption, which is `create/4` with the page's own file standing in
for the upload.

Through `create/4` and nothing else, so it inherits every rule a picked file
meets: the authorization, the shelf cap, the whitelist, the rights stamp and
the AI scan. `attrs` are the add form's, the rights tick included — the page
holds this file, but releasing it for editorial use with a credit is still a
decision somebody makes rather than one adoption may imply.
"""
def adopt_page_logo(%Organization{} = organization, %User{} = viewer, attrs) do
case page_logo_source(organization) do
nil ->
{:error, :invalid_file}

path ->
create(organization, viewer, {path, Path.basename(path)}, Map.put(attrs, "logo", true))
end
end

# A member's kit has no page logo to adopt, and the editor offers the button
# on a page alone — but the event arrives from a client, so the clause that
# cannot happen answers rather than raising.
def adopt_page_logo(_owner, _viewer, _attrs), do: {:error, :forbidden}

@doc """
Edits one stored picture's label, credit and caption — the only three columns
a form may write once the file is on disk (`Image.press_kit_update_changeset/2`
Expand Down Expand Up @@ -592,6 +687,29 @@ defmodule Vutuv.PressKit do
end
end

@doc """
What the editor's credit field is **offered** ready-filled with: the last
picture on that shelf where there is one (`last_credit/1`), and the owner's
own name where there is not.

The fallback is the whole point. A press picture with no credit is one a
journalist may not print, and until now a first-time member met an empty field
— the shelf they were filling had nothing to copy a line from. The name is
right far more often than blank is: most members are photographed by somebody
they can name, and the ones who took the picture themselves are named
correctly by it.

The **owner's** name, never the uploader's: a page's press kit belongs to the
page, and the colleague who uploads for it is not who the credit names.

A default, not a value. It is rendered into an ordinary editable input and
nothing writes it at save time, so a member who clears the field stores an
empty credit — the field says what will be stored, which is the only way a
prefill can be honest.
"""
def credit_default(owner, images) when is_list(images),
do: last_credit(images) || Identity.display_name(owner)

@doc """
Releases a press picture the AI gate cleared, and answers `:stale` when the row
is no longer the one that was waiting (deleted meanwhile, or already settled by
Expand Down
39 changes: 38 additions & 1 deletion lib/vutuv/sitemap.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ defmodule Vutuv.Sitemap do
organizations: chunks(Repo.aggregate(indexable_organizations(), :count)),
organization_posts: chunks(Repo.aggregate(indexable_organization_posts(), :count)),
jobs: chunks(Repo.aggregate(indexable_jobs(), :count)),
press: chunks(Repo.aggregate(indexable_press_users(), :count))
press: chunks(Repo.aggregate(indexable_press_users(), :count)),
organization_press: chunks(Repo.aggregate(indexable_press_organizations(), :count))
}
end

Expand Down Expand Up @@ -111,6 +112,27 @@ defmodule Vutuv.Sitemap do
end)
end

@doc """
`{path, lastmod_date}` entries of one organization-press chunk (1-based): the
press page of every crawlable page that actually offers a picture (#2087).

Its own type rather than a widened `press_entries/1`, for the reason
`organization_post_entries/1` is its own: that one is scoped to
`indexable_users/0` and reads a member's handle for the URL, and a page has
neither — its URL is built from the slug and its indexability is its `seo?`
flag. `lastmod` is the page's own, matching its directory entry.
"""
def organization_press_entries(chunk) do
indexable_press_organizations()
|> order_by([o], o.id)
|> window(chunk)
|> select([o], {o.slug, o.updated_at})
|> Repo.all()
|> Enum.map(fn {slug, updated_at} ->
{"/organizations/#{slug}/press", NaiveDateTime.to_date(updated_at)}
end)
end

@doc "`{path, lastmod_date}` entries of one posts chunk (1-based)."
def post_entries(chunk) do
indexable_posts()
Expand Down Expand Up @@ -219,6 +241,21 @@ defmodule Vutuv.Sitemap do
where(indexable_users(), [u], u.id in subquery(owner_ids))
end

# The page twin (#2087), and the other half of that nullable pair: a picture
# whose `organization_id` is set names a page's press section. Same `IN` over
# owner ids for the same reason — one row per page, however many pictures it
# offers — and the same explicit `not is_nil`, which here keeps a *member's*
# rows out.
defp indexable_press_organizations do
owner_ids =
from(i in PressKit.public_query(),
where: not is_nil(i.organization_id),
select: i.organization_id
)

where(indexable_organizations(), [o], o.id in subquery(owner_ids))
end

# scope_visible(nil) already drops restricted posts, frozen posts and
# moderation-hidden authors; the join adds the member-level conditions.
#
Expand Down
6 changes: 5 additions & 1 deletion lib/vutuv_web/agent_docs/markdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,11 @@ defmodule VutuvWeb.AgentDocs.Markdown do
|> Enum.filter(&is_binary/1)
|> Enum.join("\n"),
organization_people(doc),
organization_open_positions(doc)
organization_open_positions(doc),
# The page's press kit (#2087), written by the same line builder the
# profile document uses, so a press picture reads the same whoever offers
# it.
section(gettext("Press"), Enum.map(doc[:press_kit] || [], &press_picture_line/1))
]
|> join_blocks()
end
Expand Down
15 changes: 15 additions & 0 deletions lib/vutuv_web/agent_docs/organization_doc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do
alias Vutuv.Organizations.Organization
alias Vutuv.Posts
alias Vutuv.Posts.Post
alias Vutuv.PressKit
alias VutuvWeb.AgentDocs
alias VutuvWeb.AgentDocs.JobPostingDoc
alias VutuvWeb.AgentDocs.PressKitDoc
alias VutuvWeb.Fediverse.Docs
alias VutuvWeb.UserHelpers

Expand Down Expand Up @@ -83,6 +85,11 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do
# asked with a nil viewer, so a post still held by moderation is absent
# here even though the page's own publishers see it in the HTML.
posts: Enum.map(posts, &post_entry/1),
# The page's press kit (issue #2087), the twin of the profile document's
# own `press_kit` list: the card is on the HTML page, so the document
# names the same pictures. The **released** ones only, like every press
# document — one that names a file must name one that can be fetched.
press_kit: press_kit_entries(organization),
# The page's Fediverse address, the same fact its card shows (nil unless
# it federates). It belongs in the doc for the reason the whole system
# exists: an agent reading the `.md` should be able to say where to follow
Expand All @@ -91,6 +98,14 @@ defmodule VutuvWeb.AgentDocs.OrganizationDoc do
})
end

# A read rather than a preload: press pictures are rows on the shared `images`
# table with no association off `organizations` (see `Vutuv.PressKit`), and the
# two shelves are rejoined here because the card shows them as one set.
defp press_kit_entries(organization) do
shelves = PressKit.published_shelves(organization)
PressKitDoc.entries(shelves.photos ++ shelves.logos)
end

# No `moved_to` arm, unlike `ProfileDoc`: a page cannot move its account
# elsewhere, so the entry is the address or nothing.
defp fediverse_entry(organization) do
Expand Down
1 change: 1 addition & 0 deletions lib/vutuv_web/agent_docs/text.ex
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ defmodule VutuvWeb.AgentDocs.Text do
|> Enum.filter(&is_binary/1),
organization_people(doc),
organization_open_positions(doc),
section(gettext("Press"), Enum.map(doc[:press_kit] || [], &press_picture_lines/1)),
footer(doc)
]
|> join_blocks()
Expand Down
9 changes: 9 additions & 0 deletions lib/vutuv_web/components/organization_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule VutuvWeb.OrganizationComponents do
alias Vutuv.Countries
alias Vutuv.Organizations
alias Vutuv.Organizations.Organization
alias Vutuv.PressKit

attr(:domain, :string, required: true)

Expand Down Expand Up @@ -252,6 +253,14 @@ defmodule VutuvWeb.OrganizationComponents do
<.manage_tab active={@active == :exclusions} navigate={"/organizations/#{@organization.slug}/exclusions"}>
{gettext("Job exclusions")}
</.manage_tab>
<%!-- The page's press kit (issue #2087). Owners and publishers, which is
`Vutuv.PressKit`'s own rule read off the powers this header already has:
the material a page hands the press out with is part of speaking for it,
and it is the one manage page a publisher reaches without holding an
administrative role. --%>
<.manage_tab :if={PressKit.manageable_by_powers?(@powers)} active={@active == :press} navigate={PressKit.editor_path(@organization)}>
{gettext("Press")}
</.manage_tab>
<%!-- What happened to the page (issue #1336). Open to the whole team,
not only its publishers: this is news ABOUT the page rather than
speaking FOR it. --%>
Expand Down
Loading