Backend: mig 110/111 (will be renumbered after merging main), validators + helpers widened, BuildProjectCode helper + projection populator wired into List/GetByID/ListAncestors/GetTree/CCR. All internal Go tests pass. Frontend: ProjectFormFields conditional render — opponent_code on litigation, our_side renamed to Client Role on case with grouped optgroups. i18n keys for both DE and EN. fristenrechner perspective mapping widened. project-form.ts payload reader/writer + showFieldsForType toggle for new litigation block. Migration slots about to be bumped (mig 110 was claimed by euler's project_type_other on main).
560 lines
18 KiB
Go
560 lines
18 KiB
Go
package services
|
|
|
|
// Submission variable bag — builds the PlaceholderMap that
|
|
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
|
// docs/design-submission-generator-2026-05-19.md §6.2).
|
|
//
|
|
// Variables span six namespaces:
|
|
//
|
|
// firm.* process-wide (branding.Name)
|
|
// user.* caller's user row
|
|
// today.* server time in Europe/Berlin, locale-aware
|
|
// project.* paliad.projects + joined proceeding type
|
|
// parties.* paliad.parties grouped by role
|
|
// rule.* paliad.deadline_rules row keyed by submission_code
|
|
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
|
//
|
|
// Locale handling: every long-form date string is computed in both DE
|
|
// and EN; the renderer picks based on the user's lang preference. The
|
|
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
|
//
|
|
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
|
// paliad.can_see_project — unauthorised callers get the standard
|
|
// ErrNotFound before any variable construction runs.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/branding"
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// SubmissionVarsService assembles the placeholder map.
|
|
type SubmissionVarsService struct {
|
|
db *sqlx.DB
|
|
projects *ProjectService
|
|
parties *PartyService
|
|
users *UserService
|
|
}
|
|
|
|
// NewSubmissionVarsService wires the service.
|
|
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
|
return &SubmissionVarsService{
|
|
db: db,
|
|
projects: projects,
|
|
parties: parties,
|
|
users: users,
|
|
}
|
|
}
|
|
|
|
// SubmissionVarsContext is the input bundle that produces a render.
|
|
type SubmissionVarsContext struct {
|
|
UserID uuid.UUID
|
|
ProjectID uuid.UUID
|
|
SubmissionCode string
|
|
}
|
|
|
|
// SubmissionVarsResult bundles the placeholder map with the lookup
|
|
// values the handler needs for the audit row + file naming
|
|
// (rule.Name, project.case_number, etc.).
|
|
type SubmissionVarsResult struct {
|
|
Placeholders PlaceholderMap
|
|
|
|
// Resolved entities for audit + naming.
|
|
User *models.User
|
|
Project *models.Project
|
|
Rule *models.DeadlineRule
|
|
ProceedingType *models.ProceedingType
|
|
Parties []models.Party
|
|
NextDeadline *models.Deadline
|
|
|
|
// Lang is the user's UI language used to pick locale-aware values
|
|
// during the build. Returned so the renderer can use the matching
|
|
// missing-marker function.
|
|
Lang string
|
|
}
|
|
|
|
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
|
// matches the requested submission_code. Maps to 404 in the handler.
|
|
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
|
|
|
// Build resolves every entity and assembles the placeholder map.
|
|
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
|
if s.projects == nil || s.users == nil {
|
|
return nil, fmt.Errorf("submission vars: required services not wired")
|
|
}
|
|
|
|
user, err := s.users.GetByID(ctx, in.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, ErrNotVisible
|
|
}
|
|
|
|
// Visibility gate — GetByID returns ErrNotFound when the user
|
|
// can't see the project, which is exactly the 404 the handler
|
|
// wants to propagate.
|
|
project, err := s.projects.GetByID(ctx, in.UserID, in.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parties, err := s.parties.ListForProject(ctx, in.UserID, in.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
next, err := s.nextOpenDeadline(ctx, in.ProjectID, rule.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lang := user.Lang
|
|
if lang == "" {
|
|
lang = "de"
|
|
}
|
|
bag := PlaceholderMap{}
|
|
addFirmVars(bag)
|
|
addTodayVars(bag, time.Now())
|
|
addUserVars(bag, user)
|
|
addProjectVars(bag, project, pt, lang)
|
|
addPartyVars(bag, parties)
|
|
addRuleVars(bag, rule, lang)
|
|
addDeadlineVars(bag, next, project, lang)
|
|
|
|
return &SubmissionVarsResult{
|
|
Placeholders: bag,
|
|
User: user,
|
|
Project: project,
|
|
Rule: rule,
|
|
ProceedingType: pt,
|
|
Parties: parties,
|
|
NextDeadline: next,
|
|
Lang: lang,
|
|
}, nil
|
|
}
|
|
|
|
// loadPublishedRule fetches the deadline_rule that owns the given
|
|
// submission_code. Restricts to lifecycle_state='published' so drafts
|
|
// never end up shaping a real submission.
|
|
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
|
if submissionCode == "" {
|
|
return nil, ErrSubmissionRuleNotFound
|
|
}
|
|
var rule models.DeadlineRule
|
|
err := s.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules
|
|
WHERE submission_code = $1
|
|
AND lifecycle_state = 'published'
|
|
AND is_active = true
|
|
ORDER BY sequence_order
|
|
LIMIT 1`, submissionCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrSubmissionRuleNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
|
}
|
|
return &rule, nil
|
|
}
|
|
|
|
// loadProceedingType fetches the proceeding type row for the project's
|
|
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
|
// without a bound proceeding still render a meaningful template — the
|
|
// {{project.proceeding.*}} placeholders just resolve to the missing
|
|
// marker.
|
|
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
|
if id == nil {
|
|
return nil, nil
|
|
}
|
|
var pt models.ProceedingType
|
|
err := s.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE id = $1`, *id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
|
}
|
|
return &pt, nil
|
|
}
|
|
|
|
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
|
// the given project that maps to the chosen rule. Returns (nil, nil)
|
|
// when no matching deadline exists — common when the lawyer is drafting
|
|
// the submission before the system has computed its deadline row.
|
|
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
|
var d models.Deadline
|
|
err := s.db.GetContext(ctx, &d,
|
|
`SELECT id, project_id, title, description, due_date, original_due_date,
|
|
warning_date, source, rule_id, rule_code, status, completed_at,
|
|
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
|
approval_status, pending_request_id, approved_by, approved_at
|
|
FROM paliad.deadlines
|
|
WHERE project_id = $1
|
|
AND rule_id = $2
|
|
AND status = 'pending'
|
|
ORDER BY due_date ASC
|
|
LIMIT 1`, projectID, ruleID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// addFirmVars populates the firm.* namespace.
|
|
func addFirmVars(bag PlaceholderMap) {
|
|
bag["firm.name"] = branding.Name
|
|
// firm.signature_block is reserved for Phase 2; emit empty so
|
|
// templates that already reference it don't render the missing
|
|
// marker (less noisy for the lawyer).
|
|
bag["firm.signature_block"] = ""
|
|
}
|
|
|
|
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
|
// short form is the default {{today}}.
|
|
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
|
loc, _ := time.LoadLocation("Europe/Berlin")
|
|
if loc != nil {
|
|
now = now.In(loc)
|
|
}
|
|
bag["today"] = now.Format("2006-01-02")
|
|
bag["today.iso"] = now.Format("2006-01-02")
|
|
bag["today.long_de"] = formatLongDateDE(now)
|
|
bag["today.long_en"] = formatLongDateEN(now)
|
|
}
|
|
|
|
// addUserVars populates user.*.
|
|
func addUserVars(bag PlaceholderMap, u *models.User) {
|
|
bag["user.display_name"] = u.DisplayName
|
|
bag["user.email"] = u.Email
|
|
bag["user.office"] = u.Office
|
|
}
|
|
|
|
// addProjectVars populates project.* — title / case_number / court /
|
|
// patent_number / dates / our_side / proceeding metadata.
|
|
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
|
bag["project.title"] = p.Title
|
|
bag["project.reference"] = derefString(p.Reference)
|
|
// project.code is the auto-derived (or override) dotted project
|
|
// code computed by services.BuildProjectCode. Populated upstream
|
|
// by the service projection; templates that want the explicit
|
|
// override should read project.reference instead.
|
|
bag["project.code"] = p.Code
|
|
bag["project.case_number"] = derefString(p.CaseNumber)
|
|
bag["project.court"] = derefString(p.Court)
|
|
bag["project.patent_number"] = derefString(p.PatentNumber)
|
|
// project.patent_number_upc is the UPC-brief convention — kind code
|
|
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
|
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
|
// kind code is present so the lawyer's draft never sees a worse
|
|
// number than the source value.
|
|
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
|
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
|
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
|
bag["project.our_side"] = derefString(p.OurSide)
|
|
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
|
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
|
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
|
bag["project.client_number"] = derefString(p.ClientNumber)
|
|
bag["project.matter_number"] = derefString(p.MatterNumber)
|
|
if pt != nil {
|
|
bag["project.proceeding.code"] = pt.Code
|
|
if strings.EqualFold(lang, "en") {
|
|
bag["project.proceeding.name"] = pt.NameEN
|
|
} else {
|
|
bag["project.proceeding.name"] = pt.Name
|
|
}
|
|
bag["project.proceeding.name_de"] = pt.Name
|
|
bag["project.proceeding.name_en"] = pt.NameEN
|
|
}
|
|
}
|
|
|
|
// addPartyVars populates parties.* using the first row of each role.
|
|
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
|
// per design §13.6; expanded grouping is Phase 2.
|
|
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
|
var claimant, defendant, other *models.Party
|
|
for i := range parties {
|
|
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
|
switch role {
|
|
case "claimant", "kläger", "klaeger":
|
|
if claimant == nil {
|
|
claimant = &parties[i]
|
|
}
|
|
case "defendant", "beklagter", "beklagte":
|
|
if defendant == nil {
|
|
defendant = &parties[i]
|
|
}
|
|
default:
|
|
if other == nil {
|
|
other = &parties[i]
|
|
}
|
|
}
|
|
}
|
|
if claimant != nil {
|
|
bag["parties.claimant.name"] = claimant.Name
|
|
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
|
}
|
|
if defendant != nil {
|
|
bag["parties.defendant.name"] = defendant.Name
|
|
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
|
}
|
|
if other != nil {
|
|
bag["parties.other.name"] = other.Name
|
|
bag["parties.other.representative"] = derefString(other.Representative)
|
|
}
|
|
}
|
|
|
|
// addRuleVars populates rule.* — submission_code, name(_en),
|
|
// legal_source (+ pretty form), primary_party, event_type.
|
|
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
|
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
|
if strings.EqualFold(lang, "en") {
|
|
bag["rule.name"] = r.NameEN
|
|
} else {
|
|
bag["rule.name"] = r.Name
|
|
}
|
|
bag["rule.name_de"] = r.Name
|
|
bag["rule.name_en"] = r.NameEN
|
|
bag["rule.legal_source"] = derefString(r.LegalSource)
|
|
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
|
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
|
bag["rule.event_type"] = derefString(r.EventType)
|
|
}
|
|
|
|
// addDeadlineVars populates deadline.* from the next pending row. When
|
|
// no row exists the values fall through to the missing marker — the
|
|
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
|
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
|
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
|
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
|
if d.OriginalDueDate != nil {
|
|
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
|
}
|
|
// computed_from carries the human-readable anchor description
|
|
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
|
// the closest existing field — the calculator stores anchor
|
|
// metadata there. If empty we leave the placeholder unresolved.
|
|
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
|
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
|
}
|
|
bag["deadline.title"] = d.Title
|
|
bag["deadline.source"] = d.Source
|
|
_ = p // reserved for future shape decisions where the deadline
|
|
// var depends on project context.
|
|
_ = lang
|
|
}
|
|
|
|
// derefString returns *s or "" when s is nil.
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
// formatDatePtr formats a *time.Time, returning "" for nil.
|
|
func formatDatePtr(t *time.Time, layout string) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return t.Format(layout)
|
|
}
|
|
|
|
// ourSideDE returns the German legal-prose form of an our_side value.
|
|
//
|
|
// t-paliad-222: unified on the gender-neutral "-Seite" / "-Partei"
|
|
// suffix shape to match the form labels and to avoid implying the
|
|
// firm represents a single (female) natural person — a B2B patent
|
|
// practice almost always represents companies. The seven sub-roles
|
|
// map onto the post-mig-110 schema; legacy 'court' / 'both' no
|
|
// longer exist in the column.
|
|
func ourSideDE(side string) string {
|
|
switch strings.ToLower(side) {
|
|
case "claimant":
|
|
return "Klägerseite"
|
|
case "defendant":
|
|
return "Beklagtenseite"
|
|
case "applicant":
|
|
return "Antragstellerseite"
|
|
case "appellant":
|
|
return "Berufungsklägerseite"
|
|
case "respondent":
|
|
return "Antragsgegnerseite"
|
|
case "third_party":
|
|
return "Drittpartei"
|
|
case "other":
|
|
return "sonstige Verfahrensbeteiligte"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ourSideEN returns the English legal-prose form of an our_side value.
|
|
func ourSideEN(side string) string {
|
|
switch strings.ToLower(side) {
|
|
case "claimant":
|
|
return "Claimant"
|
|
case "defendant":
|
|
return "Defendant"
|
|
case "applicant":
|
|
return "Applicant"
|
|
case "appellant":
|
|
return "Appellant"
|
|
case "respondent":
|
|
return "Respondent"
|
|
case "third_party":
|
|
return "Third Party"
|
|
case "other":
|
|
return "other party"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// formatLongDateDE renders a date in the German long form
|
|
// ("19. Mai 2026"). Pure function for unit testing.
|
|
func formatLongDateDE(t time.Time) string {
|
|
months := []string{
|
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
|
}
|
|
idx := int(t.Month()) - 1
|
|
if idx < 0 || idx >= len(months) {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
|
}
|
|
|
|
// formatLongDateEN renders a date in the English long form
|
|
// ("19 May 2026").
|
|
func formatLongDateEN(t time.Time) string {
|
|
return t.Format("2 January 2006")
|
|
}
|
|
|
|
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
|
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
|
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
|
// prefixes pass through unchanged — preferring the raw shorthand over
|
|
// an incorrect prettification.
|
|
//
|
|
// Lang controls the language of connective words (Abs / Section,
|
|
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
|
// 254 published rules in the corpus today; new prefixes default to
|
|
// pass-through and a follow-up CL extends the table.
|
|
func legalSourcePretty(src, lang string) string {
|
|
src = strings.TrimSpace(src)
|
|
if src == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(src, ".")
|
|
en := strings.EqualFold(lang, "en")
|
|
|
|
switch {
|
|
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
|
if en {
|
|
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
|
}
|
|
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
|
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
|
if en {
|
|
return fmt.Sprintf("Section %s ZPO", parts[2])
|
|
}
|
|
return fmt.Sprintf("§ %s ZPO", parts[2])
|
|
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
|
if en {
|
|
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
|
}
|
|
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
|
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
|
if en {
|
|
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
|
}
|
|
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
|
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
|
if en {
|
|
return fmt.Sprintf("Section %s PatG", parts[2])
|
|
}
|
|
return fmt.Sprintf("§ %s PatG", parts[2])
|
|
case len(parts) == 2 && parts[0] == "EPC":
|
|
if en {
|
|
return fmt.Sprintf("Art. %s EPC", parts[1])
|
|
}
|
|
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
|
}
|
|
return src
|
|
}
|
|
|
|
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
|
// number: a whitespace-separated single uppercase letter followed by
|
|
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
|
// groups split the base from the kind code so the formatter can
|
|
// parenthesise the kind without touching the rest of the number.
|
|
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
|
|
|
// patentNumberUPC reformats a patent number from the DE convention
|
|
// ("EP 1 234 567 B1") to the UPC-brief convention
|
|
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
|
// else is preserved verbatim. Numbers without a recognised trailing
|
|
// kind code pass through unchanged so a lawyer's draft never sees a
|
|
// number worse than the source value.
|
|
//
|
|
// Recognised inputs:
|
|
//
|
|
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
|
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
|
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
|
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
|
//
|
|
// Pass-through:
|
|
//
|
|
// "EP 1 234 567" → "EP 1 234 567"
|
|
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
|
// "" → ""
|
|
//
|
|
// Pure function; unit-tested in submission_vars_test.go.
|
|
func patentNumberUPC(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
|
base := strings.TrimSpace(m[1])
|
|
kind := m[2]
|
|
if base == "" {
|
|
return s
|
|
}
|
|
return base + " (" + kind + ")"
|
|
}
|
|
return s
|
|
}
|