Social profiles: type the username, with the service's address shown in front of it and fixed #678

Closed
opened 2026-10-03 01:58:55 +00:00 by tiagoagueda · 0 comments
Owner

A social profile row asks for the whole address, and checks it against the service afterwards. The maintainer's list asks for the check to happen by construction: "Social profiles, for better validation, only typing username is necessary, prefixed url is already present, displayed and cannot be altered."

What exists today

Read from origin/main at f09b6010b, which holds the registry this issue builds on (#305, commit 307f090d2).

  • A row is a service, a name that is asked for only under Other, an address, then the star and the bin (templates/partials/web_links.html:44-60; the same partial draws a contact's links). The address is a URLField with assume_scheme="https"; the person types or pastes all of it (core/web_links.py:444-553, WebLinkForm, its clean at :523).
  • The service is a short key into a registry, WebLink.service (blank means Other) (core/models.py WebLink, migration core/0025_weblink_service.py). A Service says its key, name, kind, hosts, a pattern for the path, an icon and an example (core/link_services.py:100-130). The fifteen Postulo ships are the link-service plugin postulo.plugins.link_services; an instance or a package may add more through the entry-point group postulo.link_services.
  • There is no prefix in a service. The nearest things are hosts (linkedin.com, which also answers on subdomains and without www.) and example (“https://www.linkedin.com/in/name”, plugins/link_services/services.py:125). The pattern reads the path only, percent-decoded, “permissive at the end, strict at the start”: after /in/ anything is let through (services.py, the module's own docstring).
  • Service.handle(url) already pulls the name out of an address (link_services.py:168), guess() picks a service from a pasted address's host (:329), and settle() is the one rule for what a row is stored as (:349): a named service has to accept the address, Other takes any, nothing chosen lets the address say.
  • What is stored is the whole address in WebLink.url and the key in service. The archive writes ("kind", "service", "label", "url", "is_primary") (core/export.py:198, WEB_LINK_FIELDS), the candidate file reads the same block (resume/candidate.py:296-301), the API takes url and service (core/web_links.py:308, checked_rows). Format 29 of the archive and 4 of the candidate file added service.
  • The model's old docstring said “the kind is the sort of thing, never the host”; #305 rewrote it (WebLink: “the kind is the sort of thing, and the service is where it is”). So a service that fixes the host is no longer against a standing decision; the decision is already taken, in #305's own text, that a chosen service is checked.
  • Services where the address has one fixed front and a name: LinkedIn (/in/), Bluesky (/profile/), X (x.com/), Xing (/profile/), Facebook, Instagram, Threads (/@), YouTube (/@). Mastodon is on any server (any_host=True), so it has no fixed front. A LinkedIn address may also be a company, a school or a showcase page (/company/, /school/, /showcase/), which are not a person's profile.
  • The accepted pattern is generous, so what is pasted is stored as pasted: after /in/name a trailing path, a query string or a tracking parameter is let through, and url keeps all of it. Two spellings of one profile (linkedin.com/in/x and www.linkedin.com/in/x/) are two rows as far as the duplicate rule is concerned (core/web_links.py, BaseWebLinkFormSet.clean).
  • While Several social profiles is switched off the page shows a single address box with no service choice (add_single_boxes, core/web_links.py:386-406), which stores through save_only_link.

Checked against main at f09b6010b.

Proposal

  1. A prefix on a service. LinkService gains an optional prefix: the address up to where the person's own name goes, as the service writes it today (https://www.linkedin.com/in/, https://bsky.app/profile/, https://x.com/, https://www.threads.com/@). Blank means the service has no fixed front (Mastodon, a Forgejo instance, Other). It is a new name on the plugin surface, so a plugin's own services can say theirs. Eight of the nine shipped social services get one, taken from the example each already carries; Mastodon, which is on any server, gets none.
  2. The row, when its service has a prefix. The address box becomes a username box inside Basecoat's input-group, with the prefix as a fixed start addon: text, not an input, so it cannot be edited and can still be selected and copied. The prefix is written left to right inside <bdi dir="ltr"> on a right-to-left page; it is tied to the box by aria-describedby, so a screen reader hears the box's name and then where it goes; the group wraps on a narrow screen as the telephone field does ([data-phone-field], partials/phone_widget.html).
  3. One rule for what is typed. What the person types, stripped, is the name, and the stored address is prefix + name, through the same settle() that checks every other address. A leading @ typed where the prefix ends in @ is taken as part of the prefix. A whole address pasted into the box is recognised (it starts with http) and read as an address, so pasting never breaks; Service.handle() takes the name out of it and the stored form is the same canonical one.
  4. The stored shape does not change. WebLink.url stays the full address and service the key: the API, the archive (29 now), the candidate file (4 now), a CV's printed link, the contact export and the duplicate rule all keep reading one thing. There is no migration and no bump.
  5. A stored row that is not prefix + name (a company page, linkedin.com without www., an address with a query string or a trailing path) is shown as it is stored, in the whole-address box, with a line saying that it is kept as written; nothing is rewritten and nothing is refused in retrospect, the rule #305 set.
  6. Scripts off. A new row's service starts on “—” (#305), so with scripts off its box is the whole-address box, as today. Once the row is saved the server draws the service's own prefix. With scripts on, choosing a service shows its prefix and narrows the box, in step with the icon app.js already swaps (data-service-select); choosing Other or a service with no prefix gives the whole-address box back. The label of the box says “Username” beside a prefix and “Address” otherwise.
  7. Tighter handle rules where a service publishes one, as plain additions to each pattern (X is 1 to 15 letters, digits or underscores, already in the pattern), each with a comment naming where the rule is written; a handle outside a rule is a refusal that says what the rule is, and the way out is Other. Nothing here is looked up online.

Decisions

  • Stored form. Whole address plus the service key, as now; or the name plus the service key, with the address rebuilt when read. Recommended: the whole address. It is the only form every other reader (the CV, the API, the DAV and vCard work in #328 and #660, an older archive) already understands, and the prefix of a service can change when a service moves (X was Twitter); a stored name would then silently point somewhere else.
  • A stored row that does not fit. Rewrite it to the canonical form on the next save, or keep it as written. Recommended: keep it, with the line described above, as nothing stored is refused or rewritten in retrospect (#305). Retyping the name in the short form is the person's one action to move it.
  • Which services get a prefix. Social profiles only, as the list says. Code repositories (github.com/ plus an owner, or an owner and a project) could follow the same way, but a repository is often a project (owner/project) and not a username, and the list does not ask for it. Recommended: social profiles in this issue; the prefix field is generic, so repositories are a later, one-service-at-a-time decision.
  • Mastodon and other any-host services. No fixed host, so no fixed prefix. Recommended: the whole address for now; the form people write for a Mastodon account, @name@server, could be accepted as an alternative and turned into https://server/@name. It is small and it is how the account is written, so a second commit.
  • LinkedIn's other shapes. The person's own profile is /in/; /company/, /school/ and /showcase/ are not one. Recommended: the prefix is /in/; a company page is chosen as Other or stays as an older stored row.
  • The single box when the feature is off. The one address box has no service choice, so it has no prefix. Recommended: leave it as it is; switching Several social profiles off is exactly “the way Postulo was before services”.
  • The API. url still takes the whole address. Recommended: no new field in this issue; a client that knows a handle can build the address from the example, and a handle field would be a second way to say one thing.

Builds on

#305 (the service registry, the link-service plugin kind, settle), #213 (what the row is first, then the value), #189 (web links as rows), #303 (the star and the bin), #301 (Basecoat's select; the service choice lives in it), #638 (open; a name with accents must pass, which the path patterns already do). The prefix field is read by #654 if a brand mark is ever shown beside it.

Done when

  • Each shipped social service has its prefix in plugins/link_services/services.py, and a test in tests/test_link_services.py holds all of them: the prefix is https, is on one of the service's hosts, service.accepts(prefix + "name") is true, service.handle(prefix + "name") gives the name back, and a service with no fixed front says so.
  • Typing name on a LinkedIn row stores https://www.linkedin.com/in/name with the service linkedin; typing @name on a Threads row stores https://www.threads.com/@name; a whole address pasted into the box stores the same canonical address; a name with an accent is accepted; a name that breaks the service's rule is refused beside the box with a sentence saying what the rule is. tests/test_link_services.py and tests/test_web_links.py.
  • A stored row that is not in the canonical form is drawn with the whole-address box and is saved again unchanged when untouched. tests/test_link_services.py.
  • In a browser: choosing a service shows its prefix beside the box and nothing can be typed into the prefix; choosing Other restores the address box; with scripts off a saved row shows its prefix. tests/e2e/test_link_services.py.
  • Accessibility: the box has a name and a description that includes the prefix, the group does not overflow at 320 pixels and wraps, the prefix is whole at 400% zoom and under the text-spacing override, and a right-to-left page keeps the prefix in order; axe is clean in both themes (tests/e2e/test_accessibility.py, tests/e2e/test_text_spacing.py, tests/e2e/test_direction.py).
  • The new name on the plugin surface has its line in the wiki's Writing a plugin (tests/test_wiki_surface.py) and a test in tests/test_plugin_surface.py. The archive and the candidate file are unchanged, which tests/test_export.py and the candidate fingerprint in tests/test_candidate_file.py hold.
  • “Username” and the sentence for a kept-as-written row are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); a service's name is not translated, and the plugin's own strings stay in the plugin's catalogues.

From the list of improvements of 2026-10-02.

A social profile row asks for the whole address, and checks it against the service afterwards. The maintainer's list asks for the check to happen by construction: "Social profiles, for better validation, only typing username is necessary, prefixed url is already present, displayed and cannot be altered." ## What exists today Read from `origin/main` at `f09b6010b`, which holds the registry this issue builds on (#305, commit `307f090d2`). - A row is a service, a name that is asked for only under *Other*, an address, then the star and the bin (`templates/partials/web_links.html:44-60`; the same partial draws a contact's links). The address is a `URLField` with `assume_scheme="https"`; the person types or pastes all of it (`core/web_links.py:444-553`, `WebLinkForm`, its `clean` at `:523`). - The service is a short key into a registry, `WebLink.service` (blank means *Other*) (`core/models.py` `WebLink`, migration `core/0025_weblink_service.py`). A `Service` says its key, name, kind, `hosts`, a `pattern` for the path, an icon and an `example` (`core/link_services.py:100-130`). The fifteen Postulo ships are the `link-service` plugin `postulo.plugins.link_services`; an instance or a package may add more through the entry-point group `postulo.link_services`. - **There is no prefix in a service.** The nearest things are `hosts` (`linkedin.com`, which also answers on subdomains and without `www.`) and `example` (“https://www.linkedin.com/in/name”, `plugins/link_services/services.py:125`). The pattern reads the path only, percent-decoded, “permissive at the end, strict at the start”: after `/in/` anything is let through (`services.py`, the module's own docstring). - `Service.handle(url)` already pulls the name out of an address (`link_services.py:168`), `guess()` picks a service from a pasted address's host (`:329`), and `settle()` is the one rule for what a row is stored as (`:349`): a named service has to accept the address, *Other* takes any, nothing chosen lets the address say. - What is stored is the whole address in `WebLink.url` and the key in `service`. The archive writes `("kind", "service", "label", "url", "is_primary")` (`core/export.py:198`, `WEB_LINK_FIELDS`), the candidate file reads the same block (`resume/candidate.py:296-301`), the API takes `url` and `service` (`core/web_links.py:308`, `checked_rows`). Format 29 of the archive and 4 of the candidate file added `service`. - The model's old docstring said “the kind is the sort of thing, never the host”; #305 rewrote it (`WebLink`: “the kind is the sort of thing, and the service is where it is”). So a service that fixes the host is no longer against a standing decision; the decision is already taken, in #305's own text, that a chosen service is checked. - Services where the address has one fixed front and a name: LinkedIn (`/in/`), Bluesky (`/profile/`), X (`x.com/`), Xing (`/profile/`), Facebook, Instagram, Threads (`/@`), YouTube (`/@`). Mastodon is on any server (`any_host=True`), so it has no fixed front. A LinkedIn address may also be a company, a school or a showcase page (`/company/`, `/school/`, `/showcase/`), which are not a person's profile. - The accepted pattern is generous, so what is pasted is stored as pasted: after `/in/name` a trailing path, a query string or a tracking parameter is let through, and `url` keeps all of it. Two spellings of one profile (`linkedin.com/in/x` and `www.linkedin.com/in/x/`) are two rows as far as the duplicate rule is concerned (`core/web_links.py`, `BaseWebLinkFormSet.clean`). - While *Several social profiles* is switched off the page shows a single address box with no service choice (`add_single_boxes`, `core/web_links.py:386-406`), which stores through `save_only_link`. Checked against `main` at `f09b6010b`. ## Proposal 1. **A prefix on a service.** `LinkService` gains an optional `prefix`: the address up to where the person's own name goes, as the service writes it today (`https://www.linkedin.com/in/`, `https://bsky.app/profile/`, `https://x.com/`, `https://www.threads.com/@`). Blank means the service has no fixed front (Mastodon, a Forgejo instance, *Other*). It is a new name on the plugin surface, so a plugin's own services can say theirs. Eight of the nine shipped social services get one, taken from the `example` each already carries; Mastodon, which is on any server, gets none. 2. **The row, when its service has a prefix.** The address box becomes a username box inside Basecoat's `input-group`, with the prefix as a fixed start addon: text, not an input, so it cannot be edited and can still be selected and copied. The prefix is written left to right inside `<bdi dir="ltr">` on a right-to-left page; it is tied to the box by `aria-describedby`, so a screen reader hears the box's name and then where it goes; the group wraps on a narrow screen as the telephone field does (`[data-phone-field]`, `partials/phone_widget.html`). 3. **One rule for what is typed.** What the person types, stripped, is the name, and the stored address is `prefix + name`, through the same `settle()` that checks every other address. A leading `@` typed where the prefix ends in `@` is taken as part of the prefix. A whole address pasted into the box is recognised (it starts with `http`) and read as an address, so pasting never breaks; `Service.handle()` takes the name out of it and the stored form is the same canonical one. 4. **The stored shape does not change.** `WebLink.url` stays the full address and `service` the key: the API, the archive (29 now), the candidate file (4 now), a CV's printed link, the contact export and the duplicate rule all keep reading one thing. There is no migration and no bump. 5. **A stored row that is not `prefix + name`** (a company page, `linkedin.com` without `www.`, an address with a query string or a trailing path) is shown as it is stored, in the whole-address box, with a line saying that it is kept as written; nothing is rewritten and nothing is refused in retrospect, the rule #305 set. 6. **Scripts off.** A new row's service starts on “—” (#305), so with scripts off its box is the whole-address box, as today. Once the row is saved the server draws the service's own prefix. With scripts on, choosing a service shows its prefix and narrows the box, in step with the icon `app.js` already swaps (`data-service-select`); choosing *Other* or a service with no prefix gives the whole-address box back. The label of the box says “Username” beside a prefix and “Address” otherwise. 7. **Tighter handle rules where a service publishes one**, as plain additions to each pattern (X is 1 to 15 letters, digits or underscores, already in the pattern), each with a comment naming where the rule is written; a handle outside a rule is a refusal that says what the rule is, and the way out is *Other*. Nothing here is looked up online. ## Decisions - **Stored form.** Whole address plus the service key, as now; or the name plus the service key, with the address rebuilt when read. **Recommended:** the whole address. It is the only form every other reader (the CV, the API, the DAV and vCard work in #328 and #660, an older archive) already understands, and the prefix of a service can change when a service moves (X was Twitter); a stored name would then silently point somewhere else. - **A stored row that does not fit.** Rewrite it to the canonical form on the next save, or keep it as written. **Recommended:** keep it, with the line described above, as nothing stored is refused or rewritten in retrospect (#305). Retyping the name in the short form is the person's one action to move it. - **Which services get a prefix.** Social profiles only, as the list says. Code repositories (`github.com/` plus an owner, or an owner and a project) could follow the same way, but a repository is often a project (`owner/project`) and not a username, and the list does not ask for it. **Recommended:** social profiles in this issue; the `prefix` field is generic, so repositories are a later, one-service-at-a-time decision. - **Mastodon and other any-host services.** No fixed host, so no fixed prefix. **Recommended:** the whole address for now; the form people write for a Mastodon account, `@name@server`, could be accepted as an alternative and turned into `https://server/@name`. It is small and it is how the account is written, so a second commit. - **LinkedIn's other shapes.** The person's own profile is `/in/`; `/company/`, `/school/` and `/showcase/` are not one. **Recommended:** the prefix is `/in/`; a company page is chosen as *Other* or stays as an older stored row. - **The single box when the feature is off.** The one address box has no service choice, so it has no prefix. **Recommended:** leave it as it is; switching *Several social profiles* off is exactly “the way Postulo was before services”. - **The API.** `url` still takes the whole address. **Recommended:** no new field in this issue; a client that knows a handle can build the address from the `example`, and a `handle` field would be a second way to say one thing. ## Builds on #305 (the service registry, the `link-service` plugin kind, `settle`), #213 (what the row is first, then the value), #189 (web links as rows), #303 (the star and the bin), #301 (Basecoat's select; the service choice lives in it), #638 (open; a name with accents must pass, which the path patterns already do). The prefix field is read by #654 if a brand mark is ever shown beside it. ## Done when - Each shipped social service has its `prefix` in `plugins/link_services/services.py`, and a test in `tests/test_link_services.py` holds all of them: the prefix is `https`, is on one of the service's `hosts`, `service.accepts(prefix + "name")` is true, `service.handle(prefix + "name")` gives the name back, and a service with no fixed front says so. - Typing `name` on a LinkedIn row stores `https://www.linkedin.com/in/name` with the service `linkedin`; typing `@name` on a Threads row stores `https://www.threads.com/@name`; a whole address pasted into the box stores the same canonical address; a name with an accent is accepted; a name that breaks the service's rule is refused beside the box with a sentence saying what the rule is. `tests/test_link_services.py` and `tests/test_web_links.py`. - A stored row that is not in the canonical form is drawn with the whole-address box and is saved again unchanged when untouched. `tests/test_link_services.py`. - In a browser: choosing a service shows its prefix beside the box and nothing can be typed into the prefix; choosing *Other* restores the address box; with scripts off a saved row shows its prefix. `tests/e2e/test_link_services.py`. - Accessibility: the box has a name and a description that includes the prefix, the group does not overflow at 320 pixels and wraps, the prefix is whole at 400% zoom and under the text-spacing override, and a right-to-left page keeps the prefix in order; axe is clean in both themes (`tests/e2e/test_accessibility.py`, `tests/e2e/test_text_spacing.py`, `tests/e2e/test_direction.py`). - The new name on the plugin surface has its line in the wiki's *Writing a plugin* (`tests/test_wiki_surface.py`) and a test in `tests/test_plugin_surface.py`. The archive and the candidate file are unchanged, which `tests/test_export.py` and the candidate fingerprint in `tests/test_candidate_file.py` hold. - “Username” and the sentence for a kept-as-written row are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); a service's name is not translated, and the plugin's own strings stay in the plugin's catalogues. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#678
No description provided.